DDD 落地:实体、值对象与聚合根订单实战
上周帮一个团队做代码走查打开他们的Order类四十多个属性清一色{ get; set; }OrderService三千二百行一个名为Recalculate的私有方法在五个地方被调用。他们问我这套代码再迭代半年会怎样。我说不用半年下个季度你加一条拼团价规则就会发现已经没人敢动它了。这类场景几乎是我接触 DDD领域驱动设计的固定入口。聚合根、实体、值对象这三个词听起来像学术名词实际是三个很朴素的工程约定哪些东西有身份、哪些东西只有值、哪些东西必须一起改。把这三件事分清楚代码的可维护性会有一个台阶式的变化。这篇内容适合两类人看。一类是写了几年业务代码、被 Service 层越写越厚折磨过的人另一类是想引入 DDD 但被各种术语绕晕、迟迟不敢下手的团队。我会用一个订单场景从零写到能跑把每一步的取舍理由讲清楚也会把我这几年踩过的坑完整摊开——包括那些官方文档不会写的排查过程。先解决一个搜索层面的误会。你搜实体两个字搜出来的结果里混着 flexsim 的实体数量限制、creo 里封闭曲面无法生成实体、solidworks 多实体转装配体、catia 把完整面拆成两块、用友 U9C 的公共扩展字段和实体扩展字段、甚至虚拟机文件迁移到物理机。这些实体跟 DDD 里的 Entity 一点关系都没有。它们是几何体、是数据表映射、是 ERP 里的自定义列。本文只谈领域模型里的那三个积木。1. 把实体这个词的歧义先拆开1.1 四种语境下的实体含义完全不同在 CAD 工具里实体指的是有体积的三维几何体所以会出现不是实体转装配体这种说法在 ORM 语境里实体约等于一张表的映射字段和列一一对应在 ERP 系统里实体扩展字段指的是给某张业务单据表加自定义列在 DDD 里实体是一个判断这个对象在它的生命周期内是不是靠一个标识来区分彼此。判断标准只有一个——当你改变它的属性时它还是不是同一个东西。用户改了手机号他还是同一个人所以用户是实体。收货地址从朝阳区某某路 1 号改成海淀区某某路 8 号业务上我们并不关心这是同一个地址被改了还是换了一个新地址我们只关心内容本身所以地址是值对象。1.2 加了 ID 不等于它是实体很多人误以为给类加个Id属性就是实体了。反过来才对因为它是实体所以需要 ID 来标识身份。值对象也可以带 ID那只是持久化时为了方便不改变它作为值对象的本质。我见过一个典型反例团队给Money类加了自增主键和独立表然后在业务代码里到处传递MoneyId。结果一个金额对象的生命周期被拉长到整条业务链路上任何一次金额调整都要去改这张表还出现了两个不同订单共用同一个金额记录的诡异数据。这就是把值对象当实体处理的后果。1.3 贫血模型为什么会必然失控业务规则少的时候把规则写在 Service 里完全没问题甚至更快。问题在于规则会增长而且是以组合的方式增长。订单有 5 个状态每个状态允许的操作不同有 3 种渠道每种渠道的价格计算方式不同有 4 种优惠类型可以叠加。规则从3 条独立的 if变成5×3×4 的组合矩阵。这时候贫血模型的问题就暴露了状态字段是公开可写的任何一处代码都能把它改成任意值Service 层拿到的对象永远不知道是不是合法的。对比项贫血模型充血模型状态修改入口任意 setter只有业务方法规则位置散落在多个 Service收敛在领域对象内部单元测试需要构造大量外部依赖直接 new 调方法 断言新增规则时要搜索所有调用点通常只改一个类初期开发速度快稍慢规则超过 20 条后明显失控相对稳定这张表不是要论证充血模型一定更好而是说明一件事规则密度低的模块用贫血模型更划算规则密度高的模块必须把规则收到领域对象内部。判断标准在最后一节会展开。2. 值对象判定、不可变与相等性2.1 两个问题就能判定一个对象是不是值对象第一个问题两个属性完全一样的对象能不能互换使用如果能它是值对象。Money(100, CNY)和另一个Money(100, CNY)互换没有任何影响。第二个问题它有没有独立于宿主对象的生命周期如果没有它是值对象。金额离开订单就没有意义地址离开用户或订单也没有意义。两个问题都是是就不要给它做主键、不要给它建仓储、不要让它被单独查询。这不是教条是因为一旦给它独立的生命周期你就得处理它的并发修改、它的孤儿数据、它和宿主不同步的问题而这些成本换不来任何业务价值。2.2 不可变是值对象的地基值对象最大的价值是消除改到一半的中间状态。做法很简单构造完就不能再改。C# 里用recordJava 里用final class加全final字段再加静态工厂TypeScript 里用readonly加Object.freeze。public sealed record Money { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { if (string.IsNullOrWhiteSpace(currency) || currency.Length ! 3) throw new ArgumentException(币种必须是三位 ISO 代码, nameof(currency)); // 统一保留两位避免 0.1 0.2 这类浮点与舍入问题 Amount Math.Round(amount, 2, MidpointRounding.ToEven); Currency currency.ToUpperInvariant(); } public static Money Cny(decimal amount) new(amount, CNY); public Money Add(Money other) { if (Currency ! other.Currency) throw new InvalidOperationException(不同币种不能直接相加); return new Money(Amount other.Amount, Currency); } public Money Multiply(int quantity) new(Amount * quantity, Currency); public override string ToString() ${Amount:0.00} {Currency}; }注意Add和Multiply返回的是新对象不是修改自己。这带来一个很实在的好处当你需要在代码里把变量全改掉时编译器会拦着你。有人搜过C# 实体改变量引用怎么全改如果对象是不可变的你根本没法在心里留一个某个引用还指向旧值的隐患因为旧值不会被就地修改你只能重新赋值。想派生一个新值就在旧值基础上with一下比手写 setter 清爽得多var discounted original with { Amount original.Amount * 0.9m };with表达式会走一遍构造器也就是会重新执行校验。这一点非常关键——如果你用with生成Money(-5, CNY)构造器里的规则会立刻拦住它。用 setter 改字段就完全没有这个保护。2.3 相等性重写 equals 和 hashCode 不是可选项只要你的值对象会进集合、会做去重、会当字典的 key、会在断言里比较就必须有基于值的相等性判断。这是最容易被跳过、也最容易在半夜出问题的地方。我遇到过一起优惠券重复发放的事故根因就是CouponCode这个值对象没实现equals导致用Set去重时按对象地址比较逻辑上相同的码被当成两个不同的值。C# 的record和 Java 的record会自动生成基于字段的相等性这也是我推荐用它们而不是手写 class 的原因之一。反过来Java 里给实体类挂 Lombok 的Data是个经典坑。Data会生成基于全部字段的equals和hashCode而实体的相等性应该只看 ID。更麻烦的是它会生成 setter把你辛苦封装的业务方法全部绕过去。实体的正确打开方式是Getter加显式的业务方法需要相等性时按 ID 手写Data、Setter留给 DTO 和值对象。2.4 值对象落库的三种方式代价不一样方式适用场景好处代价值转换器压成单列字段少、不需要单独查询表结构最简迁移容易无法按内部字段过滤、排序格式变更要洗数据内嵌多列Owned / Embeddable需要按金额、城市等条件查询可索引、可聚合查询列数膨胀空值语义要特别留意独立表集合型值对象关系清晰便于统计多一次 join容易诱发再给它加个 ID的冲动我的默认选择是内嵌多列因为绝大多数查询最终都会落到按订单金额排序这类需求上。单列压缩我只在两个场景用字段是纯展示性的或者这个值对象的内部结构注定要频繁演进且不参与查询。2.5 校验放进去测试数据别用真实信息手机号、身份证、邮编这类格式校验放在值对象的构造器里最合适。放在 Controller 里做意味着只要有一个入口忘了校验脏数据就进库了。public sealed record Address { public string Province { get; } public string City { get; } public string Detail { get; } public string Receiver { get; } public string Phone { get; } public Address(string province, string city, string detail, string receiver, string phone) { if (string.IsNullOrWhiteSpace(province)) throw new ArgumentException(省份不能为空); if (string.IsNullOrWhiteSpace(detail)) throw new ArgumentException(详细地址不能为空); if (string.IsNullOrWhiteSpace(receiver)) throw new ArgumentException(收货人不能为空); if (!Regex.IsMatch(phone ?? , ^1[3-9]\d{9}$)) throw new ArgumentException(手机号格式不正确); Province province; City city; Detail detail; Receiver receiver; Phone phone; } public bool SameCityAs(Address other) Province other.Province City other.City; }写单元测试的时候用13800000000这种一眼能看出是示例的号码别把线上用户的真实号码粘进测试代码。测试数据跟着代码进仓库后面清理起来很麻烦。这类示例号码本身就是编造的不具备任何真实指向性用它反而是一种保护。3. 聚合根一致性边界不是最大的那个类3.1 聚合真正保证的是什么聚合是一个事务内必须保持一致的边界。这句话拆开有三层意思一次数据库事务只修改一个聚合聚合内部的不变式在任何一次修改后都必须成立聚合之间的数据一致性靠领域事件异步达成不靠数据库事务。很多人把聚合根理解成这个模块最主要的那个类这是错的。聚合根是入口是唯一允许外部持有的引用是内部不变式的最后一道防线。它跟重要程度没关系只跟边界在哪里有关系。3.2 从不变式反推边界划聚合边界不要凭直觉拿不变式去问。所谓不变式就是任何时候都必须成立的事实。订单场景里订单总金额等于所有明细小计之和是一条不变式明细数量必须大于零是一条不变式已发货订单的收货地址不可修改也是一条不变式。然后针对每一条不变式问这两块数据有没有可能被两个并发请求同时修改并且必须立刻互相看到对方的结果如果是它们必须在同一个聚合里。判断问题倾向合并进同一聚合倾向拆成两个聚合是否必须同一事务原子修改是否是否会被高并发争抢否是删除时是否必须一起删是否数量是否会无限增长否是跨对象访问频率偏高很低订单和订单明细必须在一起因为总金额的不变式要求它们原子更新。订单和商品不在一起因为下单时锁定的是商品的价格快照商品本身改价不需要立刻影响已下单的订单。3.3 外部只能通过根访问内部实体这条规则落地时最容易走样。正确的做法是内部实体的构造器和修改方法设为internal外部只能通过聚合根的方法间接操作它。public class OrderItem { public ProductId ProductId { get; } public string ProductName { get; private set; } public Money UnitPrice { get; private set; } public int Quantity { get; private set; } public Money Subtotal UnitPrice.Multiply(Quantity); internal OrderItem(ProductId productId, string name, Money unitPrice, int quantity) { ProductId productId; ProductName name; UnitPrice unitPrice; Quantity quantity; } // 只允许聚合根调用 internal void IncreaseQuantity(int delta) { if (delta 0) throw new ArgumentOutOfRangeException(nameof(delta)); Quantity delta; } }为什么较真这个因为绕过聚合根直接改明细等于绕过了不变式校验。今天只是改个数量明天有人直接从仓库里按OrderItemId查出来循环改总金额就悄悄对不上了。用internal有个额外好处它是编译期约束不依赖代码规范或评审自觉。顺带说一句IReadOnlyListOrderItem Items比ListOrderItem更好前者能防止外部往集合里塞东西。3.4 跨聚合用 ID 引用不要用对象引用Order里应该存CustomerId而不是Customer对象。原因有三个加载整个对象图会把无关数据全拖进来一次查询可能变成几十条 SQL并发边界会被放大改订单顺手就把用户也锁住了序列化时循环引用很容易炸。用 ID 引用的代价是你需要数据时得再查一次。这个代价通常可以接受而且可以通过读模型查询专用的 DTO来规避——写模型追求一致性读模型直接连表查两边分开。这也是 CQRS 在很多业务系统里真正有价值的地方而不是为了时髦。3.5 聚合越小越好大聚合的典型症状是保存一次订单库存、优惠券、积分全在一个事务里被锁住任何一处慢查询都会拖垮整个下单接口。我的经验阈值是一个聚合的实体数量控制在个位数修改操作的入口方法不超过十个。超了这个量级先怀疑边界划错了而不是怀疑性能不够。4. 把订单聚合完整写一遍4.1 先列不变式清单再写代码这一步别跳。先写下业务上必须永远成立的事实再决定类怎么设计代码会顺很多。只有草稿状态的订单可以增加或修改明细。明细数量必须大于零。同一商品的重复添加要合并成一行不能出现两行同商品。已发货的订单不允许修改收货地址。没有任何明细的订单不允许提交。订单总金额等于所有明细小计之和且币种必须一致。这六条里有四条是校验两条是计算。它们全部应该落在聚合根和内部实体上不该出现在 Service 里。4.2 聚合根的实现public class Order { private readonly ListOrderItem _items new(); private readonly ListIDomainEvent _domainEvents new(); public OrderId Id { get; private set; } public CustomerId CustomerId { get; private set; } public Address ShippingAddress { get; private set; } public OrderStatus Status { get; private set; } public int Version { get; private set; } // 乐观锁 public IReadOnlyListOrderItem Items _items; public IReadOnlyListIDomainEvent DomainEvents _domainEvents; private Order() { } // 给 ORM 留的无参构造器 public Order(OrderId id, CustomerId customerId, Address address) { Id id; CustomerId customerId; ShippingAddress address ?? throw new ArgumentNullException(nameof(address)); Status OrderStatus.Draft; } public Money TotalAmount _items .Select(i i.Subtotal) .Aggregate(Money.Cny(0m), (acc, x) acc.Add(x)); public void AddItem(ProductId productId, string name, Money unitPrice, int quantity) { EnsureDraft(); if (quantity 0) throw new ArgumentOutOfRangeException(nameof(quantity)); var existed _items.FirstOrDefault(i i.ProductId productId); if (existed is not null) { existed.IncreaseQuantity(quantity); return; } _items.Add(new OrderItem(productId, name, unitPrice, quantity)); } public void RemoveItem(ProductId productId) { EnsureDraft(); _items.RemoveAll(i i.ProductId productId); } public void ChangeAddress(Address newAddress) { if (Status OrderStatus.Shipped) throw new InvalidOperationException(已发货订单不能修改收货地址); ShippingAddress newAddress ?? throw new ArgumentNullException(nameof(newAddress)); } public void Submit() { EnsureDraft(); if (_items.Count 0) throw new InvalidOperationException(空订单不能提交); Status OrderStatus.Submitted; _domainEvents.Add(new OrderSubmitted(Id, CustomerId, TotalAmount)); } public void ClearDomainEvents() _domainEvents.Clear(); private void EnsureDraft() { if (Status ! OrderStatus.Draft) throw new InvalidOperationException($当前状态 {Status} 不允许修改订单); } }几个细节值得说明。private Order() { }是给 ORM 用的不是给业务代码用的。有些团队会给它加[Obsolete]或写成protected防止业务代码误用。Version字段是乐观锁。EF Core 里配成并发令牌JPA 里加Version。这个字段是防并发覆盖的第一道防线代价极低收益极高。DomainEvents用列表收集不在方法内部直接发布。原因很简单如果发布动作在方法里执行事务回滚了但事件已经发出去了下游系统会收到一个不存在的订单。正确做法是仓储保存成功后统一发布。4.3 领域事件什么时候发// 应用服务层 public async Task SubmitAsync(OrderId id, CancellationToken ct) { var order await _repo.FindByIdAsync(id, ct) ?? throw new InvalidOperationException(订单不存在); order.Submit(); await _repo.SaveAsync(order, ct); // 事务提交后再发避免回滚后消息已投递 foreach (var e in order.DomainEvents) await _publisher.PublishAsync(e, ct); order.ClearDomainEvents(); }如果用的是事务性发件箱Outbox做法略有不同事件先和业务数据一起写进同一张发件箱表再由后台任务轮询发送。这种方式能彻底解决保存成功但消息发送失败的问题代价是多一次入库和一张表。数据一致性要求高的场景支付、库存建议上发件箱普通通知类事件直接发就行。4.4 仓储只对聚合根开口public interface IOrderRepository { TaskOrder? FindByIdAsync(OrderId id, CancellationToken ct default); Task AddAsync(Order order, CancellationToken ct default); Task SaveAsync(Order order, CancellationToken ct default); }不要给OrderItem建仓储。这不只是洁癖是技术上的必须如果明细能被单独加载和保存那聚合根的不变式就形同虚设你永远无法确定当前拿到的订单对象是不是完整的。同理仓储接口应该定义在领域层实现放在基础设施层。领域层不依赖任何数据库框架这样聚合根的单元测试可以完全不碰数据库。5. 四个高频坑的完整排查链路5.1 症状改了值对象保存后没生效第一步确认对象是不是真的不可变。C# 里如果Money是class而不是record又被人加了个set就会出现改了一个共享引用的实例另一个订单也跟着变了的问题。排查方式是在两个不同的订单上断点比较金额对象的引用地址。第二步查 ORM 的快照机制。JPA 的Embeddable如果没有正确实现相等性脏检查会认为字段没变化于是不生成 UPDATE 语句。EF Core 的OwnsOne相对好一些但如果用值转换器压成了单列字段内部的变化它也感知不到。第三步查集合映射。EF Core 里OwnsMany如果不配UsePropertyAccessMode有些版本会走字段访问而不是属性访问导致变更跟踪失效。修复方案按优先级值对象必须是不可变的集合型值对象改用独立表或显式的中间表必查的一条是打开 SQL 日志确认到底有没有生成 UPDATE 语句这一步能省掉大量猜测。5.2 症状保存一个订单别的订单明细被删了这个坑我在两个项目里都见过排查过程几乎一样。先看 SQL 日志。如果发现DELETE FROM order_item WHERE order_id ?后面紧跟一批INSERT说明 ORM 把保存聚合实现成了全删全插。这在 EF Core 里通常是OwnsMany配了Cascade且对象被重新构造导致的。全删全插的副作用比想象中大自增 ID 会变、审计字段会丢、数据库触发器会被重复执行、如果有别的表外键引用明细会直接报错。再看一遍新增的迁移文件。有一次事故的原因是同事给OrderItem加了一个ProductId外键EF Core 默认推断成一对多级联删除于是删除商品时把历史订单明细一起删了。这种默认行为在大表上非常危险原则是跨聚合的外键一律显式设为Restrict或NoAction级联删除只保留在聚合内部。修复后要做的验证也很具体跑一次下单-改单-再查的完整流程逐条比对数据库里的行数和自增 ID 是否连续。5.3 症状加了乐观锁库存还是超卖如果库存和订单在同一个聚合里乐观锁能解决超卖。如果它们在两个聚合里乐观锁就管不了因为两个事务各自更新各自的表谁也拦不住谁。正确的思路是先把边界调对。库存扣减和订单创建必须是两个独立事务它们之间的一致性靠预留机制来保证下单时先发一条库存预留消息库存服务在本地事务里扣减并返回结果订单服务收到成功回执才把订单置为已确认。超时未收到回执就补偿释放。这套机制听起来复杂但它带来的好处是每个聚合都可以独立横向扩展。相比之下那种在一个事务里锁住订单表、库存表、优惠券表、积分表的做法在流量上来之后基本没法优化。5.4 症状聚合根写不了单元测试这个问题几乎总是同一个原因聚合根里注入了仓储或者领域服务。一旦注入了单元测试就得 mock 一堆东西测试覆盖率自然上不去。解药是让聚合根保持纯内存计算。需要外部数据时在调用聚合方法之前由应用服务查好作为参数传进去。上面AddItem里传unitPrice就是这个思路——价格从哪里来是应用层的决策Order只负责用它算账。这么写之后测试就变成了这个样子没有任何 mockvar order new Order(new OrderId(Guid.NewGuid()), new CustomerId(1), 测试地址()); order.AddItem(new ProductId(100), 测试商品, Money.Cny(29.9m), 2); order.AddItem(new ProductId(100), 测试商品, Money.Cny(29.9m), 3); Assert.Equal(5, order.Items.Single().Quantity); Assert.Equal(Money.Cny(149.5m), order.TotalAmount);这类测试执行时间在毫秒级一个聚合根写三四十个用例完全没负担改规则时的回归成本几乎为零。6. 不是所有项目都该全量上 DDD6.1 用规则密度决定投入判断一个模块值不值得建聚合看它的规则密度。规则密度可以粗略算成业务规则条数 ÷ 字段数量。像用户表这种字段十几个、规则就只有用户名唯一、密码格式两条密度很低用 ActiveRecord 或者直接 CRUD 更划算。像订单计价这种字段不多但规则有几十条还互相交叉密度很高值得投入。硬把一个 CRUD 模块包上聚合根、领域事件、仓储接口只会让代码变长不会变好。我见过最极端的例子是一个只有增删改查的配置表被包了五层改一个字段名要动八个文件。6.2 和 ORM 共存的三条规定第一实体和值对象允许带 ORM 注解但注解不能影响业务语义。如果某个框架逼着你把Version暴露成公开可写的那就在基础设施层用单独的持久化模型做映射领域模型保持干净。第二聚合内部的集合要么用内嵌集合要么用聚合根的 ID 做外键不要用中间表 独立仓储的方式管理。第三不要让延迟加载穿透聚合边界。Order.Customer这种导航属性一律不要需要客户信息就在应用服务里显式查一次。6.3 团队推进的四个阶段阶段做法观察指标一只识别值对象把金额、地址、编号独立出来出现重复校验的次数二核心模块建聚合根规则从 Service 迁进领域对象Service 层行数变化三引入领域事件跨聚合改为异步协作事务平均持有时长四读写分离查询侧做专门的读模型复杂查询的响应时间我强烈建议从第一阶段开始而且只做一个模块。值对象的改造风险最小、收益最直接哪怕最后团队决定不走 DDD把金额和地址封装成值对象这件事也不会白做。直接从第三阶段开始的团队我见过三个最后都退回了贫血模型。最后分享一个小技巧给每个聚合根写一个AssertInvariants()私有方法把所有不变式集中写在一处在单元测试里和调试代码里调它。业务代码里可以只在关键方法末尾调一次。这个方法看起来有点多余但当聚合根长了三个月、规则加到十几条之后它会成为你最可靠的回归防线——我自己就是靠它抓到过一次合并同商品时忘记更新价格快照的隐蔽 bug。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →