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 领域建模与数据库映射的完整流程
下图展示了从数据库表结构到领域模型的完整映射流程,以及值对象、实体、聚合根之间的关系:
3.5 领域建模中的常见反模式
在实际项目中,以下反模式容易导致模型与数据库脱节:
- 贫血模型:领域对象仅包含 getter/setter,业务逻辑散落在 Service 层。应通过值对象和实体方法封装行为。
- 过度使用原始类型:所有字段都用 String、Integer 表示,丢失了类型安全和业务语义。应优先使用值对象。
- 外键直接暴露为实体引用:在领域模型中直接持有另一个实体的引用,导致聚合边界模糊。应使用 ID 引用。
- 忽略数据库约束的领域含义:将唯一约束、非空约束等仅视为数据库特性,未在领域模型中体现为不变规则。
最佳实践总结:
- 每个聚合根对应一张主表,值对象通过嵌入式或序列化方式存储。
- 主键封装为值对象,提供类型安全和领域语义。
- 外键在领域模型中表现为聚合根 ID 的值对象引用,而非实体引用。
- 字段类型映射时,优先考虑值对象封装,将校验和业务规则内聚在值对象内部。
- 数据库约束(唯一、非空、检查)应在领域模型中有对应的不变规则。
通过以上设计,数据库表结构不再是简单的数据容器,而是领域模型的忠实映射,为后续的数据库迁移流水线提供了稳定、可演化的基础。