2026/10/13 13:15:52

深入DDD战术设计:聚合、仓储与领域事件实战指南

深入DDD战术设计:聚合、仓储与领域事件实战指南 1. 这篇要解决什么问题DDD 系列写到第三篇前两篇我们基本把战略层的东西聊透了限界上下文怎么划、上下文映射图怎么画、统一语言怎么在团队里落地。但大多数团队卡住的地方不是建模而是建模之后那一步——把聚合、值对象、领域服务这些战术设计落到真实代码里的过程。这一步走不顺前面再漂亮的模型图最后也会变成贫血模型加一个巨大的 Service 层代码架构和设计图各说各话。所以这一篇我打算把重心放在战术落地上。我会以一个电商下单场景为主线拆解聚合如何建模、仓储接口怎么设计才不会被 ORM 绑架、应用服务到底该干多少活、领域事件什么时候用以及在跨聚合协作里怎么处理事务边界。整个系列的目标读者是那些已经理解 DDD 概念、正准备在自己项目里动手尝试的开发者你不需要是架构师只要写过几个中大型接口、被乱成一锅粥的业务代码折磨过这篇里的很多东西你都会感同身受。文章里涉及的核心代码示例统一用 C# 和 EF Core 风格来写但思路是完全语言无关的你用 Java、TypeScript、Go 都一样能对照迁移。我会尽量避免空洞的理论复述每个结论都给出推导过程和实操中的取舍依据。2. 聚合建模边界半径决定代码质量上限2.1 聚合边界到底怎么划三问法聚合是 DDD 战术设计里最容易被误用的概念。很多人把聚合理解成一个大对象里面套一堆小对象结果造出了一个几十个属性的巨型根实体每个方法都又长又绕。我自己的经验是划聚合边界可以用三问法来快速判断。第一个问题没有这个根子对象能不能独立存在不能就放进同一个聚合。比如订单和订单行订单行脱离订单没有业务含义这是一个聚合。收货地址如果是复制进订单的、不跟着用户资料变化的那它就在订单聚合内部。第二个问题修改的时候需要保证哪些数据同时满足一致性需要就划进同一个聚合。第三个问题这个对象被多少个独立的业务场景修改被修改得越频繁、修改入口越分散的对象越应该独立成聚合因为修改入口多意味着并发冲突的概率高聚合边界本质上是并发边界。我见过一个真实的错误案例某团队把订单、订单行、支付记录、退款记录全部塞进一个聚合理由是这些都属于一笔交易。结果所有操作都要锁整笔订单高并发下单时数据库锁竞争严重而且每次加载订单都要把支付和退款历史全部查出来性能非常难看。把支付记录拆出去单独成聚合后订单聚合只关心自己生命周期内的行项目支付聚合只管支付状态两个聚合之间通过事件通信问题立刻缓解。2.2 事务边界与不变量聚合为什么不能被打散聚合模型对应的第一原则是一次事务只修改一个聚合。这不是 DDD 的教条而是分布式事务代价太高之后的务实选择。如果你在一个业务用例里同时改了订单和库存而这两个分属不同聚合那就得考虑本地事务能不能覆盖覆盖不了就得引入消息、补偿等机制复杂度是指数级上升的。不变量Invariant是聚合内部必须始终成立的条件。比如订单的总金额必须等于所有订单行金额之和订单行数量不能为负数已发货的订单不能新增行。建模的时候这些不变量必须封装在聚合根的方法里由聚合自己保证。写成下面的形式public class Order : AggregateRoot { private readonly ListOrderLine _lines new(); public OrderId Id { get; private set; } public OrderStatus Status { get; private set; } public Money TotalAmount CalculateTotal(); private Money CalculateTotal() { var total _lines.Aggregate(Money.Zero, (sum, line) sum.Add(line.Subtotal)); return total; } public void AddLine(ProductId productId, int quantity, Money unitPrice) { if (Status ! OrderStatus.Pending) throw new InvalidOperationException(只有待支付状态的订单才能添加明细); if (quantity 0) throw new ArgumentException(数量必须大于零); var existing _lines.FirstOrDefault(l l.ProductId productId); if (existing ! null) existing.IncreaseQuantity(quantity); else _lines.Add(new OrderLine(productId, quantity, unitPrice)); } }注意这里我把_lines设置成了私有字段外部只能通过AddLine方法来变更。这是聚合建模里最容易被忽视的动作集合属性一旦暴露成ListOrderLine任何调用方都能绕过业务规则往里塞脏数据聚合就成了一个装饰品。正确做法是暴露只读接口或者干脆不暴露只提供行为方法。这里有个经常被问到的点聚合内的不变量校验到底应该放在哪里。我的答案是优先放在聚合方法内部因为这属于聚合自身责任的完整性校验。跨聚合的校验比如下订单前用户是否被拉黑则应该放到应用服务或领域服务里做因为那是协作规则不是单一聚合的不变量。2.3 聚合适配的坑过度建模与误用集合聚合建模最常见的过度设计是万物皆聚合。值对象能解决的问题非要用聚合去表达结果就是系统里飘着几十个聚合互相之间有复杂的引用关系每个用例都要跨多个聚合做编排事务边界混乱不堪。判断一个对象到底是实体还是值对象标准很简单它有没有身份标识以及它在业务上是否被追踪。订单有实体身份因为我要查订单 123 的状态但订单金额、收货地址、产品快照信息这些是值对象——它们描述的是是什么样而不是是哪条记录。值对象是不可变的替换而不是修改这样在缓存和并发场景下都更安全。我见过有人给地址加了个 Id 字段理由居然是以后可能要单独改它。真出现这种需求时说明业务的限界上下文已经变了你应该重新建模而不是未雨绸缪地给值对象加身份这属于典型的过度设计。集合的使用也要克制。一个订单持有几十个订单行、每个订单行又持有商品的完整快照这种聚合加载出来动辄几百 K 的数据每次查询都很沉重。如果你的业务场景大部分时候只关心订单头和最近几行那就要考虑用轻量描述 按需加载的方式而不是把整个聚合一刀切地加载。聚合边界划小一点、加载策略灵活一点比后面做各种查询优化更治本。3. 仓储层基础设施依赖的火力隔离带3.1 Repository 的本质是在隔离什么仓储Repository是聚合的持久化出口。听到出口这个词你可能觉得它就是 DAO 换个名字但两者的设计动机完全不同。DAO 是面向数据表的它关心怎么把一行数据查出来塞进一个对象里Repository 是面向聚合的它关心怎么让领域层不感知数据库的存在让聚合像活在内存里一样自在。换句话说仓储隔离的不是SQL 语句这一层而是持久化技术选型这一层。今天是 EF Core明天可能是 Dapper后天可能改存 MongoDB。领域层通过一个接口去拿聚合根本不用关心背后是关系库还是文档库。这个隔离带来的直接好处是领域层的单元测试不需要起数据库你只需要一个实现了仓储接口的内存假对象就能把完整业务跑通。3.2 接口怎么设计持久化友好的四个原则仓储接口设计有四个我踩过坑之后总结出来的原则。第一接口的粒度要和聚合对齐。一个聚合对应一个仓储接口方法名应该使用聚合的语言比如GetById、Add、Save而不是InsertOrderLine、UpdateOrderStatus这种面向表的操作。第二不要在仓储接口里暴露IQueryable。IQueryable一旦出现领域层就间接依赖了 ORM 的查询能力而且调用方可以无限地拼接查询逻辑把过滤器、排序、分页全丢给仓储实现等于把数据访问层重新打开了一个口子。第三仓储方法要表达业务意图。比如获取所有待处理的订单不应该写成FindByStatus(OrderStatus.Pending)而应该是GetPendingOrders()这样领域层的调用方读代码时不需要猜业务含义。第四仓储不负责事务。事务边界属于应用服务和UnitOfWork仓储只做聚合的加载与持久化。看一个相对合理的接口public interface IOrderRepository { TaskOrder? GetByIdAsync(OrderId id); Task AddAsync(Order order); Task SaveAsync(Order order); TaskIReadOnlyListOrder GetPendingOrdersAsync(); }对应的 EF Core 实现里SaveAsync通常做的不是主动Update而是把聚合对象标记为已被跟踪提交时由工作单元统一把状态变更写回数据库。这背后的原因是聚合内部状态的变更是通过调用业务方法发生的EF 的变更跟踪器能够自动发现属性变化不需要我们手动把每个字段指过去。如果你发现自己在仓储实现里逐个属性赋值去同步数据库说明聚合根本没有被正确加载到上下文中或者你的仓储设计降级成了 DAO。3.3 一次持久化替换实验得出的结论我自己做过一次很极端的实验一个老项目原本用 EF Core 存储订单聚合后来为了验证仓储隔离的效果我把数据访问改成用 MongoDB 存文档。领域层代码只改了两处——仓储实现类需要重写以及服务注册那里换一下实现。订单相关的业务规则、事件发布逻辑、应用服务完全没动。这个实验证明了设计隔离的收益当持久化方式变化时业务代码不该跟着抖动。但这里要坦白一个反直觉的经验仓储隔离不是免费的。它会带来一层额外的映射代码尤其是 EF Core 和领域模型之间如果存在字段命名差异、值对象转换、私有字段持久化等问题时配置工作量不小。我见过不少团队用仓储模式之后一半的仓储实现变成了机械地把命令转发给 DbContext这种实现几乎不提供业务价值还需额外维护一套接口。所以在中小型项目里如果团队对 DDD 不够熟练我更建议采取折中方案保持领域模型和持久化模型的一致性用 EF Core 的 DbSet 直接作为仓储实现不要套接口等真正出现多持久化的需求时再抽象。为可能的未来过度设计和完全不设计一样不可取。4. 应用服务业务流程的编排水管4.1 应用服务该管什么、不该管什么应用服务Application Service是领域层的门面。它接收来自接口层的命令或查询参数协调一个或多个领域对象完成业务目标最后提交事务、返回结果。应用服务本身不承载业务规则——这句话值得多念几遍。很多人写着写着就把如果订单金额大于 1000 则需要人工审核这类规则写进了应用服务这时候你已经把领域规则偷渡到了应用层后续想在多个入口复用就会重复实现成为隐患。应用服务该做的是四件事获取聚合通过仓储、调用聚合的行为方法完成业务变更、保存聚合通过仓储或工作单元提交、发布领域事件。看一个标准的应用服务方法public class OrderApplicationService { private readonly IOrderRepository _orderRepository; private readonly IUnitOfWork _unitOfWork; public async TaskPlaceOrderResult PlaceOrderAsync(PlaceOrderCommand command) { var order Order.Create(new CustomerId(command.CustomerId)); foreach (var item in command.Items) { order.AddLine( new ProductId(item.ProductId), item.Quantity, new Money(item.UnitPrice, item.Currency)); } await _orderRepository.AddAsync(order); await _unitOfWork.CommitAsync(); return new PlaceOrderResult(order.Id.Value); } }注意这里Order.Create和order.AddLine是领域方法应用服务只是按顺序调用它们没有把任何 if 判断塞进流程里。command和返回的PlaceOrderResult都是普通的数据载体不带行为。这样划分之后业务变更的可测试性会大幅提升——你可以直接对Order写单元测试验证它在各种输入下的状态变化完全不依赖外部环境。4.2 命令参数与 DTO返回什么才不算泄漏应用服务的方法签名里参数和返回值最好都使用专用类型而不是裸的领域实体。传实体进来意味着调用方要自己从数据库加载数据这会打破应用服务的封闭性直接返回实体也有风险实体的内部状态可能被调用方误操作而且实体本身就带有一堆持久化相关的东西不适合直接作为接口传输对象。命令对象Command负责携带用户要做什么的输入数据比如PlaceOrderCommandDTO 负责携带这个操作完成后要告诉外界什么的输出结果比如订单号、支付金额、状态等。在典型的三层或多层架构中接口层从请求体反序列化出命令对象传给应用服务应用服务返回 DTO接口层再序列化给客户端。领域实体只存在于领域层和基础设施层之间不应该跨出 API 边界。这个设计在实践中有个隐藏收益命令对象天然可以作为审计日志的数据源。下单、改价、取消订单这类操作都通过命令对象表达了意图服务端只需记录命令内容就能还原完整的业务时间线而不需要从实体快照里反推。我后来在做交易对账系统时就靠这套命令日志把谁在什么时间意图做什么查得明明白白省了很多排查纠纷的时间。4.3 事务与并发应用服务里的隐形规则应用服务里的一个隐性问题是谁来提交数据库事务。DDD 的标准做法是用UnitOfWork作为工作单元聚合在事务内的变更会被统一检测最终由一次提交落库。但要特别小心一个应用服务方法里如果只加载了一个聚合并发起修改事务边界很自然如果加载了多个聚合而这个业务用例又必须保证这些聚合的变更同时成功或同时失败那就是一次事务改多个聚合的状况前面我已说过这是聚合建模的警示信号。并发处理上聚合的版本号乐观锁通常由基础设施层负责EF Core 可以在聚合根上配置一个Version属性配合RowVersion字段使用。应用服务不需要手动判断并发冲突因为在提交SaveChanges时数据库会抛出并发冲突异常应用服务捕获后转换成一个可读的业务异常返回给接口层即可。这里有个实操技巧如果你期望在聚合根层面就拦截过期数据可以在仓储的GetByIdAsync里把版本号作为查询条件的一部分查到空结果就说明数据已被他人修改这时候抛出异常比让数据库在提交阶段报错更友好、性能也更好。5. 领域事件跨聚合协作的正确姿势5.1 为什么不能用服务调服务来协作前两篇讲限界上下文时我们聊过上下文之间的协作方式但即使在一个上下文内部跨聚合的协作也会遇到同样的选择题A 聚合状态变了需要让 B 聚合跟着做点什么。最直觉的做法是应用服务里先改 A再直接调用 B 的仓储把 B 加载出来改掉。这个做法的直接问题是应用服务越来越胖而且 A 和 B 的耦合被硬编码进了业务流程里。领域事件Domain Event提供的是另一种思路A 聚合在自己状态变更成功时发布一个事实——比如OrderPlaced、PaymentSucceeded——然后谁关心这个事实谁就订阅它、做自己的处理。发布者不需要知道订阅者是谁订阅者之间互不感知。这就像是广播电台和收音机的关系电台只管播报收音机自己决定要不要接收、怎么处理。public class OrderPlacedEvent : IDomainEvent { public OrderId OrderId { get; } public CustomerId CustomerId { get; } public DateTime OccurredAt { get; } public OrderPlacedEvent(OrderId orderId, CustomerId customerId) { OrderId orderId; CustomerId customerId; OccurredAt DateTime.UtcNow; } }在聚合根方法里发布事件public static Order Create(CustomerId customerId) { var order new Order { Id new OrderId(Guid.NewGuid()), CustomerId customerId, Status OrderStatus.Pending }; order.AddDomainEvent(new OrderPlacedEvent(order.Id, customerId)); return order; }5.2 事件的发布时机与本地事务边界领域事件发布最容易出错的地方是时机。如果事件在聚合方法被调用时立刻发布而此时事务还没有提交一旦后续提交失败就会出现事件已经发出去了但业务变更没落库的幽灵事件下游订阅者拿到的是一笔不存在的订单。正确姿势是把事件暂存起来在UnitOfWork.CommitAsync()成功之后再统一发布。业界给这个机制起名叫事件收集器Event Collector 或 Outbox实现思路是聚合根内部维护一个本地事件队列提交事务时框架先把聚合的变更落库同时把未发布的事件写入一张事件表事务成功提交后再异步把事件投递到消息总线或直接调用内存订阅处理器投递成功后将事件表里的记录标记为已处理。这个过程中的取舍很清晰如果事件消费者和业务变更在同一进程内、且可以容忍异步延迟用内存事件处理器加事务后置发布就够了如果消费者是另一个服务或者你希望事件投递不丢失Outbox 表是必须的。不要天真地以为进程内同步调用事件处理器就能省掉 Outbox——同步处理器如果抛异常事务该不该回滚回滚了事件已经发给其他订阅者了怎么办这就是典型的本地事务与事件原子性问题不引入可靠事件表你迟早会踩中。5.3 最终一致性的落地方案从 Outbox 到订阅实际项目中跨聚合协作很少需要强一致。比如支付成功后需要更新订单状态、需要给用户发积分、需要通知库存预留这些动作之间没有严格的事务依赖最终一致完全够用。关键在于你要保证事件不丢、不重复、处理顺序可接受这三个底线。我用过比较省心的方案是本地 Outbox 定时发布器。具体做法业务事务提交时在同一数据库事务里写入domain_events表然后一个后台任务或 CDC 捕获把未发送的记录读出来投递到内存消息处理器或轻量消息中间件投递成功后更新published_at。这样做的好处是不需要引入重型消息队列数据库依赖团队都有坏处是如果你的事件量巨大定时轮询注定会有分钟级延迟同时对事件表膨胀要定期归档。如果是自用系统且事件消费方就在同进程内我建议先别急着上消息队列用 Mediator 或简单的委托订阅配合 Outbox 表做可靠性兜底。先让链路跑通观察事件量和消费耗时的真实水平再决定要不要上独立的中间件。过度设计在事件这块的代价尤其高因为消息基础设施的运维复杂度不是团队小的时候能轻易承受的。6. 复盘实录五类高频问题与排查方法6.1 问题速查表典型症状根因应对策略聚合对象巨大加载慢聚合边界划太大把低频数据全塞进来拆聚合、用轻量对象按需加载应用服务动辄二三百行业务规则被写在应用层把规则下沉到聚合或领域服务仓储接口里满屏FindBy...接口在面向表设计而非面向聚合按业务意图重命名并收敛方法跨聚合修改的事务到处可见漏建了领域事件硬编码协作用领域事件改写跨聚合变更领域单元测试需要连数据库仓储没有被正确抽象为仓储提供内存假实现领域层跑独立测试第一条里的聚合对象巨大排查方法很简单打开仓储加载日志看一次GetByIdAsync实际查询了多少张表、返回了多少行。如果每次打开订单都要拉出所有历史记录那不管聚合模型理论上多么合理实践中都是不可接受的。聚合设计不是纸面艺术性能数据就是最好的反馈。第二条的排查更微妙。你可以给自己的应用服务方法做一次规则密度统计——每十行代码里如果出现一个 if/throw 判定业务条件基本可以判定模型在漏血。我常用的一个自查动作是把应用服务调用的方法清单列出来逐个问自己这个属于谁的责任。当超过一半的方法行为是针对某个实体做状态变更时说明那些行为本该在实体里而不是在这里。6.2 三个独家避坑习惯先说说聚合根私有构造函数的配置问题。EF Core 默认需要无参构造函数来实例化实体导致很多人把私有构造写成 public等于对外敞开了创建半成品的口子。EF Core 实际上支持私有构造函数只需要在配置里显式声明builder.Property(o o.Id).HasConversion(...); builder.Navigation(o o.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);这一套配置完整做下来聚合根才能既被 ORM 正确实例化又不破坏封装。很多团队在这步偷懒后续就只能在代码审查时一遍遍叮嘱不准 new 订单。第二个习惯是显式设计聚合协作图。DDD 落地过程中光有类图远远不够你要在团队里画一张只有聚合之间相互关系的图——哪个聚合在什么事件发生时被哪个聚合通知。这张图不一定对着电脑画我在实际项目里是用白板每周过一遍重点检查有没有出现A 聚合直接拉 B 聚合的仓储去改数据的边出现一条就拉回来改事件。这张图比类图更接近设计本质。最后一个小习惯每一份聚合代码都附一个DoD完成的定义清单。清单内容很简单——这个聚合的不变量是什么、状态变更入口有哪些、哪些方法会在什么条件下抛异常、每次加载的成团半径连带加载哪些数据。清单不需要很长但每次代码评审必过这一关。做 DDD 最怕的不是不会建模而是建完模没人复核、没人维护边界。一个结构清晰但无人看管的聚合三个月后一定会退化成一张表配一堆服务的老样子。我的体会是DDD 的难点从来不在概念本身而在你能不能忍受把一件简单的事切开分给不同的层去管的别扭感。很多人第一次上手会觉得一个接口能搞定的事为什么要绕这么多圈但当你遇到第二个入口需要复用同一段订单规则、当你换一次持久化框架终于不用改业务代码、当你靠事件日志还原出一次半夜的订单异常时你会理解这些边界的存在不是为了好看而是为了让系统在长期演化里保持秩序。第三篇就到这里下一步你可以动手把自己负责的某个核心模块的小用例按这套流程走一遍遇到具体的建模争议欢迎在评论区带上代码来讨论。