DDD 战术设计:从实体到聚合根,构建领域模型

📅 2026/7/22 7:27:15
DDD 战术设计:从实体到聚合根,构建领域模型
DDD 战术设计从实体到聚合根构建领域模型目录实体值对象怎么判断聚合聚合根实战Order 聚合小结上一篇我们聊了战略设计统一语言、限界上下文、上下文映射。业务边界划清楚了接下来就是往边界里面填东西。每个上下文内部的领域模型该怎么设计很多人第一次接触 DDD 战术设计会把它理解成换一种方式写 Java 类。定义几个 Entity加几个 VO和传统 CRUD 似乎没什么区别。但战术设计关注的不是类怎么拆而是代码里的对象是否能表达真实业务概念业务规则是否属于正确的领域对象。如果只是把数据库表映射成 Entity再把逻辑全部写进 Service本质上仍然是传统贫血模型。对象只是数据容器行为散落在各处。战术设计要做的是反过来让对象自己管理自己的状态和规则。实体先看一个订单。订单有订单号、下单时间、订单状态、商品列表、收货地址。下单之后用户可以取消商家可以发货用户可以确认收货。整个过程中订单的状态一直在变待支付、已支付、已发货、已完成。但不管状态怎么变通过订单号总能找到它。上周下的单这周还能查到。这个订单号就是它的身份标识状态变化是它的业务行为。同时具备这两个特征的对象在 DDD 里叫实体Entity。需要注意DDD 中的实体不是数据库表的同义词。一个数据库表可能对应多个领域实体一个领域实体也不一定直接对应一张表。数据库关注的是存储和查询效率领域模型关注的是业务行为和规则。两者可能结构类似但关注点不同。publicclassOrder{privatefinalStringorderId;// 唯一标识创建后不可变privateOrderStatusstatus;// 可变状态privateListOrderItemitems;privateMoneytotalAmount;privateDeliveryInfodeliveryInfo;privateLocalDateTimecreateTime;privateOrder(StringorderId,StringuserId,DeliveryInfodeliveryInfo){this.orderIdorderId;this.statusOrderStatus.PENDING_PAYMENT;this.itemsnewArrayList();this.totalAmountnewMoney(BigDecimal.ZERO,CNY);this.deliveryInfodeliveryInfo;this.createTimeLocalDateTime.now();}publicstaticOrdercreate(StringuserId,DeliveryInfodeliveryInfo){returnnewOrder(generateId(),userId,deliveryInfo);}// 业务行为publicvoidcancel(){if(this.status!OrderStatus.PENDING_PAYMENT){thrownewBusinessException(只有待支付的订单可以取消);}this.statusOrderStatus.CANCELLED;}publicvoidpay(){if(this.status!OrderStatus.PENDING_PAYMENT){thrownewBusinessException(只有待支付的订单可以支付);}this.statusOrderStatus.PAID;}}注意几个设计细节。orderId是final的构造函数是private的创建只能通过Order.create()静态工厂方法。这样可以避免外部代码new Order()之后随意 setter绕过业务规则。领域对象通常不会暴露无约束的 setter而是通过业务方法改变状态。cancel()和pay()方法里的状态校验也体现了这一点。只有待支付的订单才能取消这条规则写在 Order 内部而不是散落在 Service 层。调用方不需要知道哪些状态允许取消直接调order.cancel()就行不满足条件实体自己会抛异常。这就是第一篇里提到的充血模型。传统架构下的 Order 通常只有 getter/setter业务逻辑全在 Service 里这种叫贫血模型。DDD 鼓励把业务规则放进实体本身让对象自己管自己。值对象再看订单里的金额。一笔 100 元的金额和另一笔 100 元的金额有区别吗从业务角度看没有。你不需要区分这个 100 元和那个 100 元。只要金额和币种相同它们就是一样的。你也不会去修改一个金额对象如果订单金额从 100 变成 120你直接用一个新的 Money(120) 替换掉旧的而不是money.setAmount(120)。这种没有唯一标识、不可变、用属性值判断相等的对象在 DDD 里叫值对象Value Object。publicclassMoney{privatefinalBigDecimalamount;privatefinalStringcurrency;publicMoney(BigDecimalamount,Stringcurrency){this.amountamount;this.currencycurrency;}publicMoneyadd(Moneyother){if(!this.currency.equals(other.currency)){thrownewBusinessException(币种不同无法相加);}returnnewMoney(this.amount.add(other.amount),this.currency);}Overridepublicbooleanequals(Objecto){if(thiso)returntrue;if(!(oinstanceofMoney))returnfalse;Moneymoney(Money)o;returnamount.compareTo(money.amount)0currency.equals(money.currency);}}amount和currency都是final的创建之后不能改。equals方法比较的是属性值不是对象地址。两个new Money(100, CNY)出来的对象equals返回true。值对象的add方法返回的是一个新对象不是修改自身。这和实体的行为方式完全不同实体是改自己的状态值对象是返回一个新的值。值对象还有一个常被忽略的价值消除基本类型迷恋Primitive Obsession。传统写法中金额用BigDecimal amount加String currency两个散字段表示方法签名是pay(BigDecimal amount, String currency)。调用方看到的是两个裸参数语义全靠注释和命名约定。用Money对象替代之后pay(Money amount)的语义一目了然。值对象让领域概念显式化而不仅仅是减少代码量。地址也是典型的值对象publicclassDeliveryInfo{privatefinalStringname;privatefinalStringphone;privatefinalStringprovince;privatefinalStringcity;privatefinalStringdistrict;privatefinalStringdetail;}收货地址不需要一个地址 ID来标识。北京市海淀区中关村大街 1 号就是这个地址换一种说法它不需要追踪这个地址昨天是什么样、今天是什么样。地址变了直接换一个新的 DeliveryInfo 对象。怎么判断什么时候用实体什么时候用值对象判断标准是这个东西在业务上需要追踪吗订单需要追踪用户要看订单状态客服要查订单历史财务要对账。所以订单是实体。金额不需要追踪100 元就是 100 元没有这个 100 元的生命周期这种说法。所以金额是值对象。地址不需要追踪大部分场景下用户改了收货地址旧地址就不用了不需要保留历史。所以地址是值对象。维度实体值对象唯一标识有订单号、用户ID没有可变性状态可变不可变相等判断比较 ID比较属性值生命周期有完整的创建、修改、销毁用完即弃替换无感典型例子Order、User、ProductMoney、Address、DateRange值对象在代码里往往比实体简单得多但它在 DDD 里的地位不低。用值对象替代裸的基本类型可以让领域模型更丰富。Money比BigDecimal amount String currency两个散字段更清晰DeliveryInfo比六个散字段更内聚。而且值对象的不可变性天然避免了很多并发和状态管理的问题。聚合实体和值对象有了下一个问题一堆相关的对象怎么组织一个订单包含多个订单项OrderItem包含收货信息DeliveryInfo包含金额Money。这些对象不是孤立的它们共同构成一个业务上完整的概念——“一笔订单”。如果你只改了 OrderItem 的数量但没有重新计算订单总金额数据就出现了不一致。聚合Aggregate本质上是一致性边界。一个业务规则如果需要同时保证多个对象的一致性这些对象应该属于同一个聚合。聚合内部的对象可以互相引用但外部不能直接访问聚合内部的对象只能通过一个统一的入口来操作。Order 聚合 │ ├── Order聚合根 ← 外部只能通过它访问 │ │ │ ├── OrderItem 1 ← 内部对象外部看不到 │ ├── OrderItem 2 │ └── OrderItem 3 │ └── DeliveryInfo ← 值对象属于聚合内部为什么需要这层约束假设外部代码可以直接操作 OrderItem// 直接修改订单项数量但没有更新总金额orderItem.setQuantity(10);// 此时 order.getTotalAmount() 还是旧值数据不一致了把 OrderItem 包在聚合内部外部代码就不能直接orderItem.setQuantity(10)必须通过 Order 来操作// 通过聚合根修改内部会自动重新计算总金额order.updateItemQuantity(itemId,10);聚合根在updateItemQuantity内部可以同时做两件事修改数量和重算金额。数据一致性由聚合根保证调用方不需要关心。但一致性边界不是越大越好。订单和用户、库存虽然有业务关联但它们不是同一个一致性边界。订单支付成功后需要通知库存扣减、积分发放但这些操作不需要和订单修改在同一个事务里完成。它们属于不同的聚合通过领域事件或领域服务协作。聚合根聚合的入口叫聚合根Aggregate Root。它是聚合内唯一对外可见的对象外部代码只能拿到聚合根的引用不能直接访问内部的 OrderItem 或 DeliveryInfo。设计聚合有几个原则值得记住。聚合要小。聚合内的对象越少锁定的范围越小并发冲突的概率越低。一个订单聚合包含订单项和收货信息就够了不要把商品信息、用户信息也塞进来。商品有自己的聚合用户也有自己的聚合。聚合之间通过 ID 引用不通过对象引用。订单聚合需要知道商品信息时不要在 Order 里持有一个 Product 对象的引用而是存一个productId。反例// 错误Order 直接持有 User 对象publicclassOrder{privateUseruser;// 加载 Order 时可能连带加载整个 User}这样做的问题有两个加载 Order 时可能连带加载整个 User 对象图两个聚合的生命周期被绑定改 User 的结构可能直接影响 Order。正确做法是只存 IDpublicclassOrder{privateStringuserId;// 只存 ID两个聚合完全解耦}两个聚合的生命周期完全独立各自维护各自的模型。这也呼应了上一篇讲的防腐层思想不同上下文保持自己的模型同一上下文内的不同聚合也保持各自独立。publicclassOrderItem{privateStringorderItemId;// 聚合内部的实体有自己的 IDprivateStringproductId;// 通过 ID 引用商品不持有 Product 对象privateStringproductName;// 快照下单时的商品名privateMoneyprice;// 快照下单时的单价privateintquantity;}OrderItem 用productId引用商品同时把下单时的商品名和价格做个快照存下来。这样商品那边改了名字或价格历史订单不受影响。你可能会问OrderItem 有orderItemId它不应该是值对象吗答案是实体。因为业务上需要区分不同的订单项用户买了两件 iPhone 15可能是两个独立的订单项不同数量、不同备注不能只靠商品 ID 判断是否相同。OrderItem 属于 Order 聚合但它仍然是实体。实战Order 聚合把前面的概念串起来来看 Order 聚合的核心行为。// 聚合根实体publicclassOrder{privatefinalStringorderId;privatefinalStringuserId;privateOrderStatusstatus;privateListOrderItemitems;privateDeliveryInfodeliveryInfo;privateMoneytotalAmount;privateOrder(StringorderId,StringuserId,DeliveryInfodeliveryInfo){this.orderIdorderId;this.userIduserId;this.statusOrderStatus.PENDING_PAYMENT;this.itemsnewArrayList();this.deliveryInfodeliveryInfo;this.totalAmountnewMoney(BigDecimal.ZERO,CNY);}publicstaticOrdercreate(StringuserId,DeliveryInfodeliveryInfo){returnnewOrder(generateId(),userId,deliveryInfo);}publicvoidaddItem(StringproductId,StringproductName,Moneyprice,intquantity){OrderItemitemnewOrderItem(productId,productName,price,quantity);this.items.add(item);this.totalAmountthis.totalAmount.add(item.getSubtotal());}publicvoidcancel(){if(this.status!OrderStatus.PENDING_PAYMENT){thrownewBusinessException(只有待支付的订单可以取消);}this.statusOrderStatus.CANCELLED;}publicvoidpay(){if(this.status!OrderStatus.PENDING_PAYMENT){thrownewBusinessException(只有待支付的订单可以支付);}this.statusOrderStatus.PAID;}}外部代码的使用方式// 创建订单OrderorderOrder.create(userId,deliveryInfo);order.addItem(productId,productName,price,2);// 取消订单聚合根自己校验状态order.cancel();调用方只和 Order 打交道不需要知道 OrderItem 怎么存、Money 怎么算、DeliveryInfo 是不是值对象。聚合根封装了内部的复杂性对外暴露的是业务行为而不是数据操作。小结战术设计里最基础的三个概念可以用一张图概括它们的关系实体有唯一标识、状态可变、封装业务规则值对象没有标识、不可变、用属性判断相等聚合把相关对象打包成一个整体通过聚合根保证数据一致性。聚合内部的模型设计好了有些业务逻辑还是放不下。下单时要扣库存、发优惠券、记录积分这些操作涉及多个聚合不属于任何一个实体。下一篇我们来聊领域服务。