☰
DDD实战:实体与值对象的边界判定,别再建模建错了
2026/10/6 16:53:58 网站建设 项目流程

做领域驱动设计这件事,我最有体会的环节不是战略设计怎么划分限界上下文,也不是事件风暴怎么开,而是最基础的战术建模问题:这个东西到底应该是实体还是值对象?很多团队DDD落地失败,不是输在高大上的架构决策上,而是折在一堆看起来人畜无害的基础概念上。实体和值对象分不清,聚合就建不牢,聚合建不牢,后面所有的领域服务、应用服务都是空中楼阁。

这篇文章把我这些年做DDD战术建模的经验一次性整理清楚,核心围绕实体和值对象的设计:为什么要有这两个概念,值对象设计时要守住哪些底线,实体建模里最容易踩到哪些坑,以及如何判断边界、如何识别重构信号。适合正在做领域建模、写业务代码、或者准备在团队里推广DDD的开发者参考。这里的很多结论不是教科书式的定义,而是实战中摔打出来的判断标准。

1. 为什么需要区分实体和值对象:建模的第一道分岔路

1.1 从数据库思维切换到领域思维

大多数写业务系统的程序员,第一套思维模型是关系型数据库给的:所有数据都放进表里,每张表必须有主键,主键唯一标记一行记录。于是当我们面对"用户""订单""地址""金额"这些概念时,第一反应就是它们都应该是实体,因为它们最终要落到不同的表里,而且每张表都有主键。

这个习惯在职场上太常见了,但它恰恰是DDD落地的最大阻力之一。DDD要求我们先从领域逻辑出发,而不是从存储出发。实体和值对象的划分,本质上并不是物理层面怎么存,而是业务层面怎么看待世界的两种不同方式:

  • 实体(Entity):在业务上有身份标识(Identity)的对象。一个实体即使所有属性都变了,只要ID不变,它还是它。
  • 值对象(Value Object):描述某个方面的特征,本身没有身份标识,完全由属性值定义。两个值对象属性完全相等,我们就认为它们是同一个。

换句话说,实体回答的问题是"你是谁",值对象回答的问题是"你是什么"。这两个问题在业务语境里从来不等价。

1.2 用一个例子说清楚两种思维

拿"人"和"人的地址"来说。你是实体,你有身份证号,你的名字可以改,你的外貌可以变,但你仍然是你。而你的"家庭住址"是值对象,它是你用属性描述出来的一个信息片段,没有独立身份,省、市、街道、门牌号这些属性一换,地址就变了。

再打个比方:一杯咖啡里的"超大杯""少冰""燕麦奶"是值对象,它们描述这杯咖啡的特征,换了一个属性,描述的就是另一杯咖啡;而"订单号ORD-2025-001"是实体,它的内容可能被反复修改,加配菜、改配送时间、调整折扣,但只要单号不变,这就是同一笔订单。

很多初学者会问,这不就是把"有没有主键"换个说法吗?并不是。数据库主键是物理行的唯一标记,而DDD的身份标识是业务概念的唯一性。比如一辆汽车的VIN码、一个公民的身份证号,它们在业务上天然就有身份,跟数据库存不存、哪张表存没有关系。反过来,一个"收货地址"哪怕你给它加一个自增主键,它在业务上仍然没有身份——你不会说"这个地址今天改名了,它还是之前的那个它"。

1.3 实体和值对象的判别维度

我这里整理了一个每日都要用到的判别表,比概念背来背去管用得多:

维度实体值对象
身份标识有,且稳定不变没有,靠属性本身
可变性允许状态变更设计为不可变
生命周期独立于其他对象存在跟随归属对象一起存在
相等性判断身份相等(ID相同)属性值全部相等
共享方式引用共享,修改影响双方拷贝共享,修改不影响对方
典型例子用户、订单、账户、商品金额、地址、颜色、时间段

这张表不是理论空谈,而是建模时真正要做的判断。我印象很深的一次是帮一个电商团队评审代码,他们把"用户地址"做成了实体,还加了主键,业务还没跑多久就遇到问题:用户改了地址,历史订单上记的地址也跟着变,财务对账时怎么都说不清楚订单当时到底发货到了哪里。这个问题的根源就是地址被当成了有身份的实体,而它实际上是一个典型的、应该被快照下来的值对象。后面会详聊这个案例。

2. 值对象设计:不可变性到底值多少

2.1 值对象的三个铁律

值对象设计有三个原则,我建议把它贴在工位上:不可变(Immutable)、自完整(Self-complete)、按值相等(Value Equality)。这三个词不是花架子,每一条都有具体的业务后果。

  • 不可变:对象一旦创建,任何属性都不能被修改。想得到新值,就new一个新的对象出来。这不是为了让代码"更安全"这么简单,而是因为它决定了值对象能不能被放心地共享。
  • 自完整:一个值对象应该携带完整的信息,不能只装着半个概念。比如金额必须同时包含数值和币种,只有数字没有币种的"金额"是残缺的;一个地址必须有省市区和详细门牌,缺了任何一个都不能算"一个完整地址"。
  • 按值相等:判断两个值对象是否相等,看的是业务属性逐个相等,而不是内存引用。这个原则直接决定了一个值对象能不能作为集合的元素、能不能做Map的key。

2.2 不可变性带来了什么

不可变对象最大的好处是可以放心大胆地共享,不用担心中间有人改坏它。这一点在并发编程里的价值尤其明显:两个线程同时持有一个Money对象,谁也改不了谁的状态,天然线程安全。而实体就不一样,一个账户对象如果被两个服务同时引用,稍不注意就会在并发下改出脏数据,所以实体通常要配合锁、事务、版本号等手段。

实际项目中另一个容易被忽略的好处是缓存友好。不可变对象可以安全地作为HashMap的key,不会被其他人修改后导致hashCode变化、找不到数据。有些团队在做规则引擎、配置中心之类的功能时,大量使用不可变值对象做键,性能和稳定性都很好。

2.3 一个标准的Money值对象

直接上例子。下面这个Money是我在项目里经常用的模板,代码是Java风格,但思路在所有语言里通用:

public final class Money { private final BigDecimal amount; private final Currency currency; public Money(BigDecimal amount, Currency currency) { if (amount == null || currency == null) { throw new IllegalArgumentException("amount and currency must not be null"); } if (amount.scale() > 2) { throw new IllegalArgumentException("amount scale must not exceed 2"); } this.amount = amount; this.currency = currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("currency mismatch"); } return new Money(this.amount.add(other.amount), this.currency); } public Money subtract(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("currency mismatch"); } return new Money(this.amount.subtract(other.amount), this.currency); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Money money = (Money) o; return amount.compareTo(money.amount) == 0 && currency.equals(money.currency); } @Override public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); } }

这个类有几个细节值得专门说一下:

  • final类外加final字段,没有任何setter,这是不可变的基础。子类想通过继承绕过去也不可能。
  • 构造函数里做业务校验。金额精度不能超过2位、币种不能为空,这种"坏数据进不来"的设计,比在业务代码里到处防要好得多。数据在创建时就被约束住了,后面的逻辑可以假设它是合法的。
  • add返回的是新对象,不是修改自己。这就是"操作产生新值"的典型写法。很多从过程式风格转过来的同学第一次看到会不习惯,但用一个星期就回不去了。
  • equals里用compareTo而不是equals比较金额,因为BigDecimal的0.00和0虽然值相同,但equals判断会不相等,而业务上它们就是同一个金额。这个细节不处理,将来就会在某个并发缓存里查不到数据。

2.4 值对象的嵌套和组合

值对象也可以嵌套其他值对象。最常见的例子就是地址:

public final class Address { private final String province; private final String city; private final String district; private final String detail; private final String postalCode; public Address(String province, String city, String district, String detail, String postalCode) { if (isBlank(province) || isBlank(city) || isBlank(detail)) { throw new IllegalArgumentException("province, city and detail are required"); } // 这里还可以做更细致的校验,比如限长、格式等 this.province = province; this.city = city; this.district = district; this.detail = detail; this.postalCode = postalCode; } public String fullAddress() { return province + city + district + detail; } public boolean sameCity(Address other) { return this.province.equals(other.province) && this.city.equals(other.city); } }

有人问,地址有五六个字段,为什么不直接塞进实体里当散字段?因为把它封装成一个值对象之后,跟地址相关的业务规则就有一个明确的落脚点:校验完整地址、拼接展示文本、判断两个地址是否同一个城市,这些方法可以放在Address类里,而不是散落在订单服务或者用户服务里。这就是值对象"自完整"的意义——它不是一个被动的数据袋子,而是携带了领域行为的小型领域对象。

组合值对象时,通常还可以让一个值对象内部持有多个值对象,比如"人"这个值对象可以持有FullName和Address,FullName本身可以持有firstName和lastName。逐层封装之后,业务方法读起来会非常接近自然语言,这就是DDD追求的"让代码成为领域语言的映射"。

2.5 不可变性会拖累性能吗

不少同学担心:每次add都new一个新对象,会不会GC压力很大?以现代JVM的水平,短生命周期的对象分配是极其廉价的操作,逃逸分析还会优化掉很多多余的堆分配。相比不可变性带来的安全性和可维护性,这种性能损失微乎其微。

真正需要担心的不是创建对象的开销,而是两个极端:一个极端是策略错误,为了不可变而在每个操作里做大量的集合复制;另一个极端是过度设计,把根本不会变化的简单数据硬套值对象模式。值对象的价值要在业务规模上来体现,几十行的小工具类硬套就显得繁琐。我在项目里的经验是,判断标准就一条:这个对象有没有被多处共享、有没有比较和运算逻辑。没有的话,用基本类型也未尝不可。

3. 实体设计:ID只是起点,生命周期才是核心

3.1 标识符:实体之所以是实体的根

有了值对象的对比,实体的核心地位就很清楚了:实体是那些必须具备身份标识的对象。在设计实体时,第一件事就是选好标识符的生成方式。常见方案我列个对比表:

方案优点缺点适用场景
数据库自增ID简洁、索引小泄露业务量、跨库迁移麻烦、分布式下需要额外方案单体应用内部
UUID分布式友好、无需中心化索引较大、对人不友好分布式系统、需要客户端先有ID
雪花ID趋势递增、分布式唯一依赖时钟、部署有要求中大规模分布式系统
业务唯一键(如订单号)可读性好可能被业务修改、不适合做关联外键只读业务键

从DDD的角度看,不建议把数据库自增ID作为实体身份的唯一来源,原因在于领域层和数据层被耦合了:应用还没在内存里创建对象,就得先往数据库插一条拿ID。更常见的做法是在应用层先生成UUID,或者在业务对象创建时就确认真实业务唯一键,数据库主键只是存储层的物理标记,不该被领域代码感知。

3.2 实体的可变性只在状态变化时才有意义

实体允许变化,但不能是毫无约束的变化。一个典型的反例就是实体类里面全是setter:

public class Order { private OrderId id; private String status; private List<OrderItem> items; public void setStatus(String status) { this.status = status; } }

这个类从仓库里取出来,业务层想怎么改状态就怎么改,status字段可以传一个随便什么字符串进去。这就是典型的贫血模型、数据容器式实体。正确的做法是把行为收到实体内部,让实体自己守护自己的业务不变量:

public class Order { private OrderId id; private OrderStatus status; private List<OrderItem> items; public void submit() { if (status != OrderStatus.DRAFT) { throw new IllegalStateException("Only DRAFT order can be submitted"); } if (items.isEmpty()) { throw new IllegalStateException("Cannot submit empty order"); } this.status = OrderStatus.SUBMITTED; } public void cancel() { if (status == OrderStatus.SHIPPED || status == OrderStatus.COMPLETED) { throw new IllegalStateException("Shipped or completed order cannot be cancelled"); } this.status = OrderStatus.CANCELLED; } }

submit()和cancel()就是实体的领域行为。它们封装了状态流转规则:什么状态下能提交、什么状态下不能取消。业务规则在实体内部,调用方只告诉实体"我要提交",而不是告诉实体"把状态字段改成SUBMITTED"。这样即使底层换框架、换数据库,核心规则仍然牢牢地留在领域层。这个设计差异在代码评审里一眼就能看出来,也是我判断团队DDD功底的一个重要信号。

3.3 生命周期:实体不是一张静态表

实体的生命周期通常比值对象长得多,而且往往伴随着一个状态机。订单是一个非常合适的例子:

  • 草稿(DRAFT)→ 已提交(SUBMITTED)→ 已支付(PAID)→ 已发货(SHIPPED)→ 已完成(COMPLETED)
  • 已提交(SUBMITTED)→ 已取消(CANCELLED)
  • 已支付(PAID)→ 退款中(REFUNDING)→ 已退款(REFUNDED)

每个状态转移都需要校验前一个状态和当前行为。把状态机画成图并不难,难的是在代码里守住它。我的建议是:不要让状态机隐式地藏在各种if判断里,而是把它封装在实体的行为方法中。比如订单的pay()、ship()、complete()方法,每个方法都对应一个允许发生的状态迁移,方法内部做前置状态校验。新同事接手代码时,光看实体上的公共方法就能理解这个对象能干什么,而不是在一堆setter里猜。

3.4 实体内部的字段形态

实体内部通常会同时包含"基础属性"和"值对象字段"。基础属性如用户昵称、手机号这种直接保存在实体上的信息;值对象字段则是指Money、Address这类嵌入实体、为实体提供完整描述的对象。

设计实体的时候有一个高频问题:订单的金额,直接在实体上存一个BigDecimal totalAmount,还是把它建模成Money值对象?我的答案永远是尽量用Money。因为金额注定要参与加减乘除、比较大小、打印展示,如果散成一个个裸的BigDecimal,业务里到处都是"忘掉币种只比数值""不同币种的金额直接相加"这类bug。实体把金额委托给Money对象,既保证了业务含义不丢失,也让实体的字段形态更接近领域语言。

3.5 实体与聚合的关系

很多人学到这里会问,聚合和实体是什么关系?我的理解是:聚合是一组以聚合根为入口的领域对象集合,聚合根是实体的特殊形态。聚合根一定有身份标识,是外界访问聚合内其他实体的唯一入口。比如Order是聚合根,OrderItem即使有独立ID,也不应该被外部直接持有和修改,而必须通过Order来访问。这个约束对领域模型的完整性极其重要。

从实践上看,实体设计最容易犯的错不是"实体不够多",而是"万物皆实体"。很多团队把订单里的每个字段都做成实体,搞得领域模型比数据库结构还复杂,结果共享引用满天飞,改一个字段到处受影响。下一章就是专门聊怎么界定边界的。

4. 实体与值对象的边界判定:几个让我纠结的真实案例

4.1 案例一:金额和货币,结论很清晰

先说最没有争议的。金额Money、币种Currency、时间区间TimeRange、颜色Color,这些在任何场景下都应该是值对象。我见过有人给Money表加自增主键,理由是"数据库表不能没主键",这个理由在持久化层说得通,但在领域模型里是硬伤。因为值对象没有独立身份,你问"这个金额是谁",这个问题本身没有意义。

如果一定要在数据库层用一张独立的表存金额,那主键只是物理存储的标记,不应该被领域代码感知,更不应该暴露到接口和业务判断中。建模时别把存储结构等同于领域结构。

4.2 案例二:地址,最容易翻车的对象

地址是教科书上经典的值对象,但在真实业务里它非常折腾。你要判断的关键不是"地址长什么样",而是"在这个业务里,地址有没有独立身份、需不需要跟踪它的变化历史"。

举个例子:电商的下单场景,收货地址应该建模成值对象,并且在下单瞬间被完整快照到订单里。因为订单一旦生成,你关心的是"这个订单当时发到哪",而不是"用户最新的地址是什么"。如果地址是实体,用户改了地址,历史订单里的地址会被追踪成新值,对账、发货记录就乱了。前面提到的那个团队踩的就是这个坑。

但如果业务是一个房产管理平台,需要记录"某栋楼在2015年到2018年之间的法定登记地址是A,后来变更登记为B",这时候地址就有明确的身份和被追踪的生命周期,必须建模成实体。同一个"地址",在两种业务里建模完全不同。结论就是:概念本身不决定它是实体还是值对象,业务上下文才决定。

4.3 案例三:商品与商品快照

电商系统里Product是典型的实体,它有SKU、有独立的商品目录、需要被编辑和查询。但订单明细里的OrderItem存的却是下单时候的商品快照,包括当时的商品名称、单价、规格。如果把这个快照建成对Product实体的引用,将来商品改名、改价,历史订单的展示、对账全部跟着变。正确的做法是在订单行项目里用一个值对象ProductSnapshot保存下单时的信息,同时保留productId作为追溯关联。

这类错误在"商品中心"和"交易系统"拆分不清的团队里特别常见。核心原因是实体引用被到处复用,这个实体的状态变化会波及所有引用它的地方。值对象在这里的价值是作为"快照"存在,让历史记录不再受当前数据变化的影响。

4.4 案例四:订单状态字段

ORDER_STATUS在很多人眼里就是一个枚举,确实,值对象和枚举在DDD里有关联,但从领域建模角度,我更愿意把它看成实体的状态字段,甚至用状态机来管理。状态本身的值可以用枚举或常量值对象表示,但状态的流转规则必须属于订单实体。也就是说,"状态是什么"可以被值对象化,但"状态怎么变"是实体的职责。

有人会问,状态值对象没有身份标识,为什么不直接用字符串?因为裸字符串会让业务失去语义。用OrderStatus.SUBMITTED比用"SUBMITTED"更容易避免拼写错误,也能把状态上的业务行为收拢在一起。将来如果要给状态流转加动作(比如发送通知、触发支付回调),直接在这个枚举或值对象上添加方法即可,调用方完全不用改。

4.5 案例五:用户手机号

用户手机号也是一个容易纠结的对象。大多数场景下,手机号是一个值对象,它是一个单纯的属性片段,没有身份标识。但有一种场景要特别注意,就是运营商后台系统,手机号的选号、过户、销号背后有一条独立的生命周期,那它就必须是实体。你看,还是同一个结论——看业务上下文。

4.6 边界判定的五连问

经过这些案例,我总结出一个五连问,每次建模拿不准的时候就走一遍:

  1. 有没有唯一不变的标识来区分两个相似的对象?有,倾向于实体;没有,倾向于值对象。
  2. 这个对象所有属性都变了,它还是原来的它吗?是,实体;否,值对象。
  3. 它需要被独立查询、独立修改、单独追踪吗?需要,实体;不需要,值对象。
  4. 它是否跟随另一个对象一起诞生、一起消亡?是,值对象的概率更大。
  5. 在两个不同的时刻,我们关心的是"它是同一个东西"还是"它的值是否相等"?前者,实体;后者,值对象。

这五连问不一定每次都能给出唯一答案,但至少能逼着我们把业务意图落到实际设计判断上。哪怕问完还在纠结,只要能把纠结的具体问题摆到桌面上,离正确建模就不远了。

5. 从错误中重构:识别建模失误的信号

5.1 信号一:值对象被迫加了ID

如果某天你发现一个"值对象"被加了一个id字段,先停下来想想这个id是用来干嘛的。比较常见的情况是,团队为了"在数据库表里区分两行相同的数据"硬塞主键。这种加ID的动作,本质上是把值对象降格成了实体,代价是丧失不可变性、增加共享风险,历史数据的一致性随之恶化。

正确的修复方式通常是:确认业务到底需不需要让这个对象有自己的身份。如果不需要,把多余的主键从领域模型中移除,存储层可以用自己的物理主键,但领域代码不感知;如果需要,就要认真评估它是不是真的应该被当成实体,以及它是否应该改成聚合根或聚合内的实体。

5.2 信号二:实体类里躺着一堆setter

实体类里除了少数真正的状态变更方法,其他都是getter/setter,这个实体十有八九已经退化成数据容器。这种模型在领域层没有行为、在服务层全是事务脚本,业务规则被散落到各个Service里,改规则时满项目找。

重构方向很清晰:把散落在服务层的业务规则往实体里搬。比如原来在OrderService.pay()里写了十几行状态校验和金额计算逻辑,就把它下沉为Order.pay()方法,OrderService只负责从仓库拿订单、调用order.pay()、再把订单存回去。核心思路是"让领域逻辑回到领域对象"。这个过程一开始会比较痛苦,因为意味着大量单元测试要从服务层挪到实体上,但挪完之后,服务层会变得非常薄、非常清晰。

5.3 信号三:到处传递裸的基本类型

一个项目里充斥着String userId、String address、BigDecimal amount参数,实际上是在用基本类型代替值对象。这种代码最大的问题不是类型安全,而是语义丢失。同一个String参数,上一秒是用户ID,下一秒变成订单状态,调用方和实现方全靠"心领神会",一旦传错,编译期完全看不出来。

我处理这类问题的经验是"先痛一次":把一个常用基本类型升级为值对象,比如把String phone改成PhoneNumber,把BigDecimal totalAmount改成Money,然后让编译错误告诉你有多少地方在裸传。一开始错误会很多,但修完之后,很多隐蔽的bug会直接消失。这种重构不太适合一次性铺开,更适合在新增功能或修复bug时顺路做,一个类型一个类型地收口。

5.4 信号四:可变共享引用引发的不一致

如果发现两个聚合共用了同一个可变实体,并且一个改了状态另一个也变,这就是典型的共享可变引用问题。实体应该尽量通过ID引用而不是直接持有可变对象。比如Order.address如果是实体,修改它会影响所有引用它的订单;如果是值对象,每个订单持有自己的快照,就不会互相影响。让共享的可变实体只保留在它自己的聚合内,外部对它的修改必须通过仓库重新获取,不要跨聚合长时持有可变实体。

5.5 我对建模过程的几条实操建议

第一,建模不是一次性的,而是迭代的。没有谁第一次设计就能把实体和值对象分得完美,关键在于保留一个可以快速调整模型的节奏。建议每个迭代留出固定的"技术债清理"时间,专门处理上一轮暴露出来的建模问题。

第二,值对象要多写,实体要克制。我的经验是,初学者倾向于把什么都做成实体,资深实践者更愿意把信息片段建模成值对象。值对象越多,模型越精细,共享越安全;实体越少,聚合的边界越清晰,系统就越不容易失控。

第三,建模评审要看行为而不看字段。评审一个领域对象,不该只问"它有哪些字段",而应该问"它对外提供了什么行为、守护了什么规则"。字段只是行为的支撑,行为才是领域的灵魂。

第四,写测试时不要只管实体。值对象是最适合做单元测试的对象,因为它的行为完全由输入决定,没有外部依赖。给Money.add、Address.fullAddress这类值对象方法补上覆盖业务规则的测试,比测试一堆Service要便宜得多、可靠得多。

5.6 一个很实用的习惯

最后分享一个我踩过很多坑之后养成的习惯:每写一个新的类,我都会先问自己,如果这个类被new两个一模一样的实例,它们除了引用地址不同之外还有没有区别?没有区别,就做成值对象;有区别,就做成实体。这个问题听起来简单,但它几乎覆盖了80%的实体/值对象设计场景。剩下20%拿不准的,就把业务上下文放上台面,用五连问走一遍。

实体和值对象的设计,说到底不是一个纯粹的技术选型问题,而是一个"你对业务的理解是否足够清晰"的问题。模型不会骗人,一旦模型和业务对齐,后面的代码、测试、迭代都会顺畅很多。这也是我始终认为DDD值得花时间学、花时间用的原因,它逼着你在写代码之前,先把业务想明白。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询