ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DDD实体与值对象:建模身份与相等性的核心指南

DDD实体与值对象:建模身份与相等性的核心指南 做后端有些年头的人基本都被一个老问题折磨过对象到底该建模成什么尤其是当你开始接触领域驱动设计DDD之后这个纠结会更明显——同样一个“地址”有时候应该被当成一个独立对象来对待有时候又不值得同样一张数据库表你和同事争论它到底是不是实体能吵一下午。这篇内容主要聊清楚两件事什么是实体Entity什么是值对象Value Object以及在实际业务里怎么判断、怎么落地。如果你正准备在项目里引入领域驱动设计或者已经在画聚合、写实体但总觉得自己建模的边界怪怪的那这篇文章应该能帮你省几天的时间。1. 实体和值对象到底是什么先解决身份和相等性这两个根本问题1.1 数据库表设计惯性带来的误区很多人接触 DDD 时最困惑的一件事就是数据库里明明是一张张表每张表都有主键为什么到了领域模型里有些对象居然没有“主键”这其实是数据库表设计和领域建模不是一回事的典型症状。在领域驱动设计里实体和值对象的划分依据不是“这张表的主键是什么”而是“这个业务概念在领域里到底靠什么来定义自己”。想清楚这件事你就不会一上来就把所有东西都建成实体也不会建出一堆没有身份却硬撑着身份的模型。我第一次做 DDD 重构时犯过一模一样的错误。当时把用户表、订单表、地址表全部映射成实体结果订单地址变成了一个带主键的独立实体系统里到处都是“查询地址”和“修改地址”的接口逻辑复杂到几乎没办法维护。后来才意识到订单里的收货地址根本不需要被单独跟踪它只是订单的一个属性快照用值对象表达才真正贴合业务。1.2 实体是有“身份”的对象身份证号类比实体是拥有身份Identity的对象。举个最直观的例子一个人无论他换过多少次住址、改过几次手机号他依然是他自己因为他的身份证号不变。订单里同样一个订单状态从“待支付”变成“已支付”它仍然是我下的那个订单它的订单号还是那个订单号。实体的特点是身份不变状态可变。这个“身份”可以让它在整个生命周期里被跟踪、被追溯比如一个订单从创建、支付、发货到完成中间经历了各种状态变化但我们始终能知道“这同一个订单”走到哪一步了。这正是实体存在的意义为业务提供一条连续的、可标识的主线索。在实际建模中实体往往对应业务中那些“需要被反复引用”“需要被单独管理”的概念。比如客户、订单、商品、账户它们都有自己的编号这个编号就是它们在系统内外的身份标识。身份一旦建立最好不要随意更换否则历史的记录、日志、关联关系都会断裂。1.3 值对象是“特征值”的组合一杯咖啡的类比值对象恰好相反它关心的不是“我是谁”而是“我是什么”。比如一个金额100元人民币就是100元人民币不管它是从张三的账户里转来的还是从李四的账户里转来的它作为金额本身没有任何变化。我经常用一个咖啡的例子来解释一杯“大杯、拿铁、少糖”这几个属性组合在一起才构成“我要的那杯咖啡”这个完整概念。如果另一个人点的也是“大杯、拿铁、少糖”那它和我点的就是同一个东西——我不关心它是“这杯”还是“那杯”因为没有必要给每一杯咖啡都发一个独立编号。值对象用它的全部属性值来描述一个业务概念当这些属性值全部相同时它们就是同一个值对象。这个特性决定了它天然应该是不可变的你不需要修改一个值对象的某个属性你只会把它整体替换成一个新的值对象。比如地址从“A街道”改成“B街道”本质上是把“旧地址值对象”替换为“新地址值对象”而不是去修改同一个地址的内部字段。2. 三个判断标准如何把一个业务概念放进正确的模型里2.1 标准一这个概念是否拥有独立身份判断一个概念应该建模为实体还是值对象第一标准就是看它是否有独立身份。这里的“身份”不是一个数据库主键而是领域层面的标识符。比如订单用订单号标识自己客户用客户编号标识自己这些标识不依赖于任何其他对象而存在。反过来看一个“金额”不会因为从这笔订单换到那笔订单而改变自己是“100元人民币”的事实一个“时间段”也不会因为属于不同项目而变成本质不同的东西。它们描述的是特征而不是个体。所以当你面对一个建模对象时先问自己如果两个对象的所有属性都相同它们还是两个不同的东西吗如果答案是否定的那它大概率是值对象如果答案是肯定的说明背后一定存在一个隐藏的身份在区分它们那它就是实体。2.2 标准二这个概念是否有独立于其他对象的生命周期第二个标准是生命周期。实体有自己独立的生命周期从创建、变更到归档全程都可以被跟踪值对象没有生命周期它随着所属实体的创建而出现随着所属实体的改变而被替换掉。举个例子一个客户的“默认收货地址”可以被维护和修改这个地址本身可能有一个地址ID会被其他业务反复引用比如“把订单发到这个地址ID”。在这种情况下它更像一个实体因为它有独立的被跟踪需求。但是订单里的“下单时的收货地址”只是当时那一个时间点的快照用来保留发货信息它不需要被后续业务引用和修改所以它更适合建模为值对象。这个区分的实操价值很大。如果所有概念都建模为实体系统会凭空多出很多“身份”和“关联”代码里的更新、删除、级联操作会指数级增加如果把有生命周期概念建模为值对象又会丢失跟踪它的能力业务上需要追溯时无从下手。2.3 标准三相等性怎么定义第三个判断标准是相等性。实体的相等性由身份决定两个实体只要ID相同不管其他属性是否相同它们就是同一个对象。值对象的相等性由所有属性决定只要属性值组合相同它们就是相等的。这个差异对代码实现有直接要求。实体的 equals/hashCode 应该基于 ID 实现值对象的 equals/hashCode 应该基于全部属性实现。如果你在设计时没有想清楚这一点放进 Set、Map 或者做对象比较时就会出现各种诡异的问题——有时候明明业务上应该是同一个对象程序却认为它们不同有时候明明是不同的对象程序却认为它们相等。用一张表来对比会更清楚维度实体Entity值对象Value Object核心特征拥有独立身份由属性值整体定义是否可变状态可变身份不变通常不可变相等性判断通过身份标识判断通过全部属性值判断生命周期有独立的生命周期没有生命周期随宿主实体产生和替换典型例子订单、客户、商品金额、地址、时间段、颜色2.4 值对象向实体升级的边界条件模型不是一成不变的。一个值对象在业务演进过程中完全有可能被“升级”成实体当业务开始要求单独跟踪它的历史、单独引用它、单独修改它的时候值对象的设计就不再够用了。我遇到过一个真实案例。最初系统里客户地址只是订单上的一个字符串快照后来业务方提出要做“地址簿”功能客户可以维护多个地址下单时选择一个地址引用。这时候地址就不再是简单的值对象了它需要有自己的ID、需要被单独查询、需要被更新。于是我们把地址从值对象升级成了实体。反过来如果一个实体被反复复制、到处引用但业务其实从来不关心它各自的“身份”那它应该被收敛成值对象。我的经验是没有明确身份需求的概念优先按值对象设计等业务给出“需要它有自己的生命周期”的信号后再谈升级这样模型更轻重构成本也更低。3. 真实案例落地电商订单里的实体与值对象设计3.1 业务需求与建模结果前面讲了理论这一节用一个非常典型的电商订单案例把实体和值对象的设计过程完整走一遍。假设我们要做一个订单系统涉及的核心概念有客户Customer、订单Order、订单项OrderItem、金额Money、收货地址Address。这些概念在数据库里可能都是一张张表但它们在领域模型里的定位各不相同。订单和客户毫无疑问是实体它们都有明确的身份标识也有独立的生命周期。订单项在这里要看情况如果订单项需要被单独跟踪比如支持部分退款、单独售后那它应该是一个实体如果订单项只是订单的一个组成部分不需要被单独引用它也可以被建模成值对象的集合。真实电商系统里订单项通常被建模为实体因为它要承担退款、售后等独立业务行为。金额和收货地址则是标准的值对象。金额由“数值币种”两个属性共同定义100元人民币就是100元人民币地址由“省市区详细地址”等属性组合定义同一个地址被复用到不同订单上完全不需要区分“这是哪个地址实例”。3.2 实体与值对象的代码实现下面用一个简化版的 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(金额和币种不能为空); } this.amount amount; this.currency currency; } public BigDecimal getAmount() { return amount; } public Currency getCurrency() { return currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException(不同币种不能直接相加); } return new Money(this.amount.add(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, currency); } }注意几个细节类用 final 修饰字段全部用 final没有 setter构造函数里直接做参数校验。金额的加法不是修改自身而是返回一个新的 Money 对象。equals 比较时用了 compareTo 而不是 equals因为 BigDecimal 的 equals 会区分精度比如 1.0 和 1.00 会被认为不相等在金额比较中显然这样不合理。再看订单实体的简化写法public class Order { private final OrderId orderId; private OrderStatus status; private ListOrderItem items; private Money totalAmount; private Address shippingAddress; public Order(OrderId orderId) { this.orderId orderId; this.status OrderStatus.CREATED; this.items new ArrayList(); this.shippingAddress null; } public void addItem(OrderItem item) { items.add(item); recalculateTotal(); } public void changeShippingAddress(Address newAddress) { this.shippingAddress newAddress; } public void confirm() { if (this.status ! OrderStatus.CREATED) { throw new IllegalStateException(只有已创建订单可以确认); } this.status OrderStatus.CONFIRMED; } private void recalculateTotal() { this.totalAmount items.stream() .map(OrderItem::getItemTotal) .reduce(Money.ZERO, Money::add); } public OrderId getOrderId() { return orderId; } }实体的字段没有全部声明为 final因为状态会变化。但订单 ID 是 final 的这是一身份标识一旦创建就不能变。对外不提供 orderId 的 setter也不直接暴露 items 集合外部只能通过 addItem 来添加条目这样可以保证订单的业务不变量比如总金额始终是正确的。3.3 值对象的持久化方案与性能考量值对象在数据库里怎么存是一个绕不开的问题。很多团队在这里翻车就是因为没有理解“值对象没有独立身份”这件事硬是给值对象建表、加主键最后把模型搞得四不像。最推荐的做法是让值对象作为实体的内嵌属性直接存储在实体所在的表里。比如订单的收货地址就可以在订单表里占用几个字段收货人、省、市、区、详细地址。用 JPA 的 Embeddable 和 Embedded 可以很方便地实现这一点。这样数据天然内聚在订单里也不会出现“地址表”被到处引用的耦合问题。如果值对象是一个集合比如订单项的某种附属信息表优先考虑用序列化 JSON 的方式存在一个字段里或者用 ElementCollection 映射成一张从表。但无论哪种方式都要明确一点这张表本身没有独立的领域含义它依附于订单存在订单删除时它就应该一起消失。我个人的实操心得是值对象尽量别单独建表除非它有被多表复用的强需求。一旦你给值对象建了带主键的表就会有同事为了“节省空间”去把它做成字典表、到处外键关联最后值的概念被稀释掉模型也就失去了表达力。还有一个性能相关的点值对象的不可变性让它可以被安全地在缓存中共享。一份 Money 对象、一个 Address 对象被多个订单引用都没有问题因为它们不会被修改。相比之下实体因为有状态缓存时总担心数据被污染需要考虑缓存失效策略。这也是值对象在系统设计里“轻”和“安全”的重要体现。4. 常见设计偏差与排查技巧我踩过的那些坑4.1 误把“有数据库主键”当成“有身份”我在做代码评审时最常发现的问题就是团队把所有有主键的表都映射成实体。最典型的是字典表、关联表、快照表它们从领域角度看根本没有“身份”可言结果被当成实体后到处都是对它们的状态修改和生命周期管理。要判断一个对象是不是伪实体有一个很实用的技巧把它的主键字段从脑子里暂时抹掉再想想这个概念能不能自洽地存在。比如“订单项”如果没有 ID它仍然可以被理解为一个订单下的某一商品行但如果“订单”没有订单号它就无法和其他订单区分开。前者更接近值对象后者才是真正的实体。另外一个线索是看这个对象是否会被“按ID查询”。如果一个对象永远是通过所属实体来访问的比如总是通过 order.getItems() 来拿到订单项列表从来没有一个“按订单项ID查询订单项”的接口那它大概率不是实体至少不需要为了它设计独立的仓储和生命周期。4.2 值对象在 ORM 框架下相等性失效值对象的 equals/hashCode 是基于全部属性实现的这和 Hibernate、JPA 等 ORM 框架的代理机制经常打架。最常见的问题是当你懒加载一个值对象时实际拿到的不是 Money 或 Address 本身而是框架生成的代理子类。这时候调 equals因为 getClass() 不相等直接返回 false导致两个属性完全相同的值对象被判断为不同对象。我遇到过最典型的一个场景把订单 A 和订单 B 的地址放到一个 Set 里做去重结果 Set 里出现了两个“看起来完全一样”的地址排查了很久才发现是 Hibernate 的代理对象在捣鬼。解决思路有三个方向。第一equals 方法里不要用 getClass() 做类型判断改用 instanceof第二比较字段时用 getter 方法而不是直接操作字段因为代理对象的字段可能没有被正确初始化第三如果你确定值对象在整个生命周期里都会被真实加载可以关闭这个聚合的懒加载配置但这个方法影响面较大需要谨慎使用。最稳妥的办法是在写值对象时就为它编写单元测试覆盖“普通对象和代理对象比较”“字段相同但引用不同”这两类场景提前把代理问题暴露出来不要等代码上线后在业务数据里踩雷。4.3 值对象的集合被外部改掉值对象本身不可变这没有什么争议但值对象内部如果放了集合事情就变得复杂了。比如某个业务对象内部有一个 List 如果直接把 list 通过 getter 返回出去外部调用方就能往里面 add、remove整个值对象的不可变性就形同虚设了。我在一个财务系统里踩过这个坑。当时设计了一个“费用明细”值对象内部用 ArrayList 存了一组金额为了图方便直接提供了 getItems() 方法。结果上游服务拿到这个 list 后往里面追加了一条不属于该业务的费用导致报表数据错乱排查了整整一天。从那以后我的规则很明确值对象内部的集合一律返回不可变副本或者在 getter 里返回 Collections.unmodifiableList(this.items)或者干脆复制一份再返回。修改值对象的唯一路径是通过显式的领域方法比如 addFee、removeFee这些方法内部创建新的值对象来返回结果。4.4 值对象序列化与版本兼容问题值对象经常被用在缓存、消息队列、RPC 接口里所以序列化问题不容忽视。我用 Java 和 Jackson 时就遇到过一个很实际的问题值对象的字段都是 final 的并且没有无参构造函数Jackson 默认反序列化时会直接报错。解决办法通常是在值对象上使用 Jackson 的 JsonCreator 和 JsonProperty 注解或者使用支持构造器绑定的序列化框架比如 Kotlin 的 data class 配合 Jackson 的 kotlin module、Java 的 record 配合适合的序列化器。一定要提前把序列化测试做好否则值对象在本地跑得好好的一上线走缓存就抛异常。另一个容易被忽略的问题是值对象的版本兼容。因为值对象是“值”它被复制和传递的范围很广一旦某个字段含义发生变化比如金额从“分”改为“元”所有缓存里的旧数据都会变成错误数据。所以我在设计值对象时会刻意在序列化格式里带上版本号或者字段名避免字段类型变更时无法识别旧数据。4.5 常见问题速查表最后整理一份速查表方便你在建模时快速对照现象可能原因处理建议一个概念有大段代码处理它的增删改查但业务却不关心它单独的编号被误建成实体尝试调整为值对象删除独立仓储两个“相同”的对象在 Set 或 Map 中无法合并equals/hashCode 实现错误或 ORM 代理问题补充单元测试按值对象规则重写 equals值对象被外部修改导致聚合内部数据错乱暴露了可变集合或 setter返回不可变副本添加显式领域方法值对象反序列化失败或字段无法解析序列化框架不支持 final 字段与无参构造约束使用构造器绑定的序列化方案提前做测试一个值对象被多个聚合共享后某处单独修改它值对象被错误地当作了共享可变实体恢复不可变性修改时创建新值对象业务需要记录某个概念的修改历史概念开始需要独立生命周期考虑升级为实体赋予合适身份标识在实际使用中实体和值对象的设计不是一次就能定死的。往往要等业务跑一段时间你才会发现某个值对象被多次“复制引用”后业务开始要求单独跟踪它的历史这时候再把它升级成实体也不迟。反过来如果你一开始什么都建模成实体后面想收敛成值对象会非常痛苦。所以我的习惯是没有明确身份需求的概念优先按值对象设计等业务给出“需要它有自己的生命周期”的信号后再谈升级。这个原则让我在好几个项目里少走了不少弯路。
返回列表