3. 数据库领域建模:表结构映射为领域模型、字段类型与值对象设计、主键与外键的领域语义

数据库领域建模是连接物理存储与业务逻辑的核心桥梁。本章将深入探讨如何将关系型数据库的表结构精确映射为领域模型,并围绕字段类型、值对象、主键与外键的领域语义展开设计,确保模型既符合数据库范式,又能忠实表达业务规则。

3.1 表结构到领域模型的映射策略

将数据库表映射为领域模型时,需遵循“一个聚合根一张表”的基本原则,但也要根据业务复杂度进行适度调整。下表总结了常见的映射模式:

数据库元素 领域模型映射 典型场景
表 (Table) 聚合根 (Aggregate Root) 或实体 (Entity) 订单表 → 订单聚合根
列 (Column) 实体属性 或 值对象 (Value Object) email 列 → Email 值对象
主键 (Primary Key) 实体标识 (Entity ID) user_id → UserId 值对象
外键 (Foreign Key) 聚合引用 (Aggregate Reference) 或 值对象 ID order.user_id → 引用 UserId
唯一约束 (Unique Constraint) 领域不变规则 (Invariant) 用户邮箱唯一性
实践要点:映射时优先考虑业务语义而非数据库范式。例如,即使数据库允许 NULL,如果业务上“客户姓名”必须存在,则领域模型应使用非空类型,并在应用层强制校验。

3.2 字段类型与值对象设计

值对象是领域驱动设计中的核心概念,它描述事物的属性,没有唯一标识,通过属性值来定义相等性。将数据库字段映射为值对象,可以显著提升模型的表达力和安全性。

3.2.1 基础类型 vs. 值对象

以下对比展示了何时使用基础类型,何时应封装为值对象:

数据库字段 基础类型映射 值对象映射 推荐理由
VARCHAR(255) email String email Email email 封装格式校验、域名逻辑
DECIMAL(10,2) price BigDecimal price Money price 封装货币单位、舍入规则
VARCHAR(20) phone String phone PhoneNumber phone 封装国家代码、格式验证
INT age Integer age Age age 封装年龄范围、业务规则
值对象设计原则:
  • 不可变性:值对象一旦创建,其属性不可修改。修改应返回新实例。
  • 自校验:在构造函数中完成所有业务校验,确保无效状态无法创建。
  • 相等性:重写 equals() 和 hashCode(),基于所有属性判断相等。

3.2.2 值对象映射到数据库

值对象在数据库中的存储方式通常有两种:

  • 嵌入式 (Embedded):值对象的属性直接展开为表的多个列。例如,Address 值对象包含 street、city、zipCode,在 orders 表中对应 shipping_street、shipping_city、shipping_zip_code。
  • 序列化 (Serialized):将值对象整体序列化为 JSON 或 XML 存储在单个列中。适用于复杂、不常查询的结构化数据。

以下是一个 Java 中值对象的典型实现示例:

public final class Email {
    private final String value;

    public Email(String value) {
        if (value == null || !value.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new IllegalArgumentException("Invalid email format: " + value);
        }
        this.value = value;
    }

    public String getValue() {
        return value;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Email email = (Email) o;
        return value.equals(email.value);
    }

    @Override
    public int hashCode() {
        return value.hashCode();
    }
}

3.3 主键与外键的领域语义

在领域模型中,主键和外键不仅仅是数据库的物理约束,它们承载着重要的领域语义。

3.3.1 主键:实体标识的领域含义

主键在领域模型中对应实体的唯一标识。设计时应考虑:

  • 业务主键 vs. 代理主键:业务主键(如身份证号)具有领域含义,但可能变更;代理主键(如自增 ID)稳定,但无业务含义。推荐在领域模型中使用代理主键,同时通过唯一约束保证业务主键的唯一性。
  • 标识值对象化:将主键封装为值对象,如 OrderId、UserId,可以避免原始类型混淆,并附加类型安全。

3.3.2 外键:聚合之间的引用关系

外键在领域模型中代表聚合之间的关联。DDD 强调通过聚合根 ID 而非对象引用来关联其他聚合,以保持边界清晰。

数据库外键 领域模型引用方式 说明
order.user_id UserId userId (值对象) 订单通过 UserId 引用用户聚合根
order_item.order_id OrderId orderId (值对象) 订单项通过 OrderId 引用订单聚合根
customer.address_id Address address (值对象,嵌入式) 地址作为值对象直接嵌入客户实体
常见陷阱:避免在领域模型中直接使用数据库外键约束来维护业务关系。外键约束是数据库层面的完整性保证,而领域模型应通过业务逻辑和仓储模式来维护关联的一致性。过度依赖数据库外键会导致模型僵化,难以应对复杂业务变更。

3.4 领域建模与数据库映射的完整流程

下图展示了从数据库表结构到领域模型的完整映射流程,以及值对象、实体、聚合根之间的关系:

数据库表结构 → 领域模型映射流程 数据库表结构 orders 表 users 表 order_items 表 addresses 表 映射规则 表 → 聚合根/实体 列 → 属性/值对象 主键 → 标识值对象 外键 → 聚合引用 领域模型 Order (聚合根) OrderId id UserId userId User (实体) UserId id Email email OrderItem (实体) OrderItemId id Money price Address (值对象) street city, zipCode

3.5 领域建模中的常见反模式

在实际项目中,以下反模式容易导致模型与数据库脱节:

  • 贫血模型:领域对象仅包含 getter/setter,业务逻辑散落在 Service 层。应通过值对象和实体方法封装行为。
  • 过度使用原始类型:所有字段都用 String、Integer 表示,丢失了类型安全和业务语义。应优先使用值对象。
  • 外键直接暴露为实体引用:在领域模型中直接持有另一个实体的引用,导致聚合边界模糊。应使用 ID 引用。
  • 忽略数据库约束的领域含义:将唯一约束、非空约束等仅视为数据库特性,未在领域模型中体现为不变规则。
最佳实践总结:
  1. 每个聚合根对应一张主表,值对象通过嵌入式或序列化方式存储。
  2. 主键封装为值对象,提供类型安全和领域语义。
  3. 外键在领域模型中表现为聚合根 ID 的值对象引用,而非实体引用。
  4. 字段类型映射时,优先考虑值对象封装,将校验和业务规则内聚在值对象内部。
  5. 数据库约束(唯一、非空、检查)应在领域模型中有对应的不变规则。

通过以上设计,数据库表结构不再是简单的数据容器,而是领域模型的忠实映射,为后续的数据库迁移流水线提供了稳定、可演化的基础。