☰
深入DDD战术设计:聚合、仓储与领域事件实战指南
2026/10/13 13:15:45 网站建设 项目流程

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 List<OrderLine> _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方法来变更。这是聚合建模里最容易被忽视的动作:集合属性一旦暴露成List<OrderLine>,任何调用方都能绕过业务规则往里塞脏数据,聚合就成了一个装饰品。正确做法是暴露只读接口,或者干脆不暴露,只提供行为方法。

这里有个经常被问到的点:聚合内的不变量校验到底应该放在哪里。我的答案是优先放在聚合方法内部,因为这属于聚合自身责任的完整性校验。跨聚合的校验(比如"下订单前用户是否被拉黑")则应该放到应用服务或领域服务里做,因为那是协作规则,不是单一聚合的不变量。

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 { Task<Order?> GetByIdAsync(OrderId id); Task AddAsync(Order order); Task SaveAsync(Order order); Task<IReadOnlyList<Order>> 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 Task<PlaceOrderResult> 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)负责携带"用户要做什么"的输入数据,比如PlaceOrderCommand;DTO 负责携带"这个操作完成后要告诉外界什么"的输出结果,比如订单号、支付金额、状态等。在典型的三层或多层架构中,接口层从请求体反序列化出命令对象,传给应用服务;应用服务返回 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 的难点从来不在概念本身,而在你能不能忍受"把一件简单的事切开分给不同的层去管"的别扭感。很多人第一次上手会觉得:一个接口能搞定的事,为什么要绕这么多圈?但当你遇到第二个入口需要复用同一段订单规则、当你换一次持久化框架终于不用改业务代码、当你靠事件日志还原出一次半夜的订单异常时,你会理解这些边界的存在不是为了好看,而是为了让系统在长期演化里保持秩序。第三篇就到这里,下一步你可以动手把自己负责的某个核心模块的小用例按这套流程走一遍,遇到具体的建模争议,欢迎在评论区带上代码来讨论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询