
做领域驱动设计这件事我最有体会的环节不是战略设计怎么划分限界上下文也不是事件风暴怎么开而是最基础的战术建模问题这个东西到底应该是实体还是值对象很多团队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和AddressFullName本身可以持有firstName和lastName。逐层封装之后业务方法读起来会非常接近自然语言这就是DDD追求的让代码成为领域语言的映射。2.5 不可变性会拖累性能吗不少同学担心每次add都new一个新对象会不会GC压力很大以现代JVM的水平短生命周期的对象分配是极其廉价的操作逃逸分析还会优化掉很多多余的堆分配。相比不可变性带来的安全性和可维护性这种性能损失微乎其微。真正需要担心的不是创建对象的开销而是两个极端一个极端是策略错误为了不可变而在每个操作里做大量的集合复制另一个极端是过度设计把根本不会变化的简单数据硬套值对象模式。值对象的价值要在业务规模上来体现几十行的小工具类硬套就显得繁琐。我在项目里的经验是判断标准就一条这个对象有没有被多处共享、有没有比较和运算逻辑。没有的话用基本类型也未尝不可。3. 实体设计ID只是起点生命周期才是核心3.1 标识符实体之所以是实体的根有了值对象的对比实体的核心地位就很清楚了实体是那些必须具备身份标识的对象。在设计实体时第一件事就是选好标识符的生成方式。常见方案我列个对比表方案优点缺点适用场景数据库自增ID简洁、索引小泄露业务量、跨库迁移麻烦、分布式下需要额外方案单体应用内部UUID分布式友好、无需中心化索引较大、对人不友好分布式系统、需要客户端先有ID雪花ID趋势递增、分布式唯一依赖时钟、部署有要求中大规模分布式系统业务唯一键如订单号可读性好可能被业务修改、不适合做关联外键只读业务键从DDD的角度看不建议把数据库自增ID作为实体身份的唯一来源原因在于领域层和数据层被耦合了应用还没在内存里创建对象就得先往数据库插一条拿ID。更常见的做法是在应用层先生成UUID或者在业务对象创建时就确认真实业务唯一键数据库主键只是存储层的物理标记不该被领域代码感知。3.2 实体的可变性只在状态变化时才有意义实体允许变化但不能是毫无约束的变化。一个典型的反例就是实体类里面全是setterpublic class Order { private OrderId id; private String status; private ListOrderItem items; public void setStatus(String status) { this.status status; } }这个类从仓库里取出来业务层想怎么改状态就怎么改status字段可以传一个随便什么字符串进去。这就是典型的贫血模型、数据容器式实体。正确的做法是把行为收到实体内部让实体自己守护自己的业务不变量public class Order { private OrderId id; private OrderStatus status; private ListOrderItem 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 边界判定的五连问经过这些案例我总结出一个五连问每次建模拿不准的时候就走一遍有没有唯一不变的标识来区分两个相似的对象有倾向于实体没有倾向于值对象。这个对象所有属性都变了它还是原来的它吗是实体否值对象。它需要被独立查询、独立修改、单独追踪吗需要实体不需要值对象。它是否跟随另一个对象一起诞生、一起消亡是值对象的概率更大。在两个不同的时刻我们关心的是它是同一个东西还是它的值是否相等前者实体后者值对象。这五连问不一定每次都能给出唯一答案但至少能逼着我们把业务意图落到实际设计判断上。哪怕问完还在纠结只要能把纠结的具体问题摆到桌面上离正确建模就不远了。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值得花时间学、花时间用的原因它逼着你在写代码之前先把业务想明白。