你不妨回想一下,自己有没有在接手一个陈旧业务系统时,做过这样的重构:把一个几百行的Service方法拆成好几个小方法,把实体类里裸露的setter全部改成带业务语义的方法,甚至把一堆if判断收拢进某个领域类里。做完的一瞬间很爽,感觉代码又“面向对象”了。但用不了几周,你会发现新代码又慢慢变回了老样子——Service重新膨胀、实体里开始出现getter/setter的裸奔、业务规则散落在各个调用方。问题出在哪里?我认为多半出在:你只修复了“代码坏味道”的表层,却没有真正建立一层防御体系。
这就是我反复强调DDD(领域驱动设计)的原因。DDD里面最容易宣传的四个关键词——聚合、聚合根、仓库、工厂——很多人能背出概念,但不知道它们其实是四道协同工作的防御盾牌,而不是四个孤立的代码模板。这四者一旦组合起来,领域模型就会形成一个“外部必须按规矩访问、内部必须按规则流转”的封闭系统。换句话说,它们存在的意义不是让代码分层更好看,而是从机制上迫使业务规则不被绕过。
这篇文章我想按照“防御体系”的视角重新讲一遍这四个概念。适合已经对DDD有初步了解、但还没想清楚“为什么一定要这样设计”的Java后端开发、架构师,以及正在做领域模型重构的团队。我会尽量少讲空泛理论,多结合可落地的代码和踩坑经验,文章较长,建议先收藏再看。
1. 问题从哪来:贫血模型为什么守不住业务规则
在讲聚合、聚合根、仓库、工厂之前,我们得先弄清一个关键问题:业务代码为什么总是守不住自己的规则?
1.1 传统的“Service + 实体”写法到底哪里不对
大多数传统项目的代码结构是这样的:
public class OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; @Transactional public void addItem(Long orderId, Long productId, int quantity) { OrderDO orderDO = orderMapper.selectById(orderId); ProductDO productDO = productMapper.selectById(productId); BigDecimal subtotal = productDO.getPrice().multiply(BigDecimal.valueOf(quantity)); OrderItemDO itemDO = new OrderItemDO(); itemDO.setOrderId(orderId); itemDO.setProductId(productId); itemDO.setQuantity(quantity); itemDO.setSubtotal(subtotal); orderItemMapper.insert(itemDO); orderDO.setTotalAmount(orderDO.getTotalAmount().add(subtotal)); orderMapper.updateById(orderDO); } }这段代码看起来没毛病,很多人每天都在写。但如果仔细看,你会发现在这里,OrderDO和OrderItemDO只是数据的搬运工,所有业务决策都在Service里做。我们称之为贫血模型。
贫血模型最大的问题不是“实体没有方法”,而是:业务规则不归属任何实体,它们散落在Service里,完全依赖程序员的“自觉”来调用。今天你记得在addItem里校验数量为正数,明天别人新增了batchAddItems,未必会记得同样的校验。结果就是:同一个业务规则,有的入口守住了,有的入口漏了。
1.2 不变量——领域模型最需要保护的东西
领域模型里最核心的东西不是属性,而是不变量(Invariant)。不变量的意思是“无论外部怎么操作,这个业务规则必须始终保持成立”。举几个典型例子:
- 订单的
totalAmount必须等于所有orderItem的subtotal之和。 - 已支付的订单不能再新增商品项。
- 下过单的客户不允许被直接删除。
- 商品库存不能为负数。
这些不变量一旦被破坏,无论代码跑得多流畅,业务上都已经是错的。贫血模型的问题在于,它用“setter+外部计算”的方式表达数据变化,等于把不变量的维护责任全推给了Service层。而Service层又是最容易膨胀、最容易被绕过的地方,自然守不住。
所以DDD的第一性原理就浮现出来了:把和不变量相关的数据和行为收拢到一个封闭的边界内部,让外部只能通过这个边界的门面(聚合根)来触发变化。这就是聚合和聚合根存在的根本理由。
2. 聚合边界:领域模型的第一道防线
2.1 聚合到底是什么:一起变化的数据和行为
在我讲过的很多案例里,用“同事关系”来理解聚合是最快的方式。你在一家公司里,一个项目组成员通常会一起协作、一起被考核、一起变动,项目组内部沟通频繁;而不同项目组之间,只通过项目经理或接口人对接,不会直接互相指挥。
聚合就是领域里的“项目组”。它把一组在生命周期上强相关、在业务规则上互为约束的对象(实体、值对象)组合在一起,并指定一个对象作为唯一对外的接口(聚合根)。
回到下单场景,订单、订单项、收货地址、金额明细这些对象应当被划进同一个聚合,叫Order。为什么?因为:
- 订单项的增删必须同步影响订单总额(
totalAmount和 所有items的subtotal存在不变量); - 收货地址可能被修改,但删除订单时,订单项和地址必须一起删除;
- 对订单项的任何变化,都必须先经过订单这个整体来决策。
这些强约束说明它们之间不是“弱关联”,而是同一个生命周期。强行拆开只会把不变量暴露到一个很广的范围,增加被破坏的概率。
2.2 聚合边界怎么划:从不变量反推范围
很多团队上手DDD第一件事就是“划分聚合”,然后一群人对着业务图争执。实际上,聚合边界不是靠“感觉”划的,而是靠不变量反推的。具体思考步骤大概是:
- 先找出领域里最重要的业务规则/不变量。
- 列出满足这些不变量需要哪些数据。
- 这些数据的变化频率和变化原因是强绑定,还是各自独立?
- 如果两个对象只有“读取”关联,没有“写一致性”要求,就别放在同一个聚合里。
有人会反问:“订单和订单项,商品和库存,是不是都应该拆开?”我的经验是:先看事务边界。如果两个对象必须在一个数据库事务里保持强一致,它们大概率应该落在同一个聚合里;如果允许最终一致(比如下完单后异步扣库存),它们就分别属于不同的聚合。
注意:聚合不是越大越好。很多团队最初把订单、商品、库存、优惠券全塞进一个
Order聚合,结果发现并发性能差、锁粒度粗、改一处全部受影响。合理聚合的判断标准是“刚好能保住不变量”,而不是“塞进所有相关对象”。
2.3 聚合设计的几个实用原则
聚合边界确定后,内部结构要遵守几条硬规矩,我在做代码评审时基本拿这几条充当“红线”:
- 聚合内部引用通过对象,聚合之间引用通过ID。
Order聚合可以持有OrderItem对象集合,但OrderItem不能持有Product对象,只能持有productId。 - 必须通过聚合根访问聚合内部成员。外部代码不允许直接修改
order.items[0].quantity,必须调用order.changeItemQuantity(...)。 - 聚合内所有持久化操作以聚合根为单位。保存订单时,连同所有订单项一起保存;删除订单,连同订单项一起删除。
- 聚合根拥有全局唯一标识,内部实体只有本地标识。
这些原则本质上是在做一件事:收敛访问入口。访问入口越少,防御点就越少,越容易把守。
3. 聚合根:唯一入口的严格守卫
3.1 为什么所有访问必须经过聚合根
聚合根(Aggregate Root)是聚合的门面,也是唯一允许被外部对象引用的对象。它的职责有两个层级:对外,接收所有请求;对内,协调聚合内部对象完成业务操作,并确保不变量不被破坏。
举个实际例子。订单状态变成已支付后,业务规则要求不能再修改商品数量。如果你把业务逻辑放在Service里,你很可能只校验了“修改数量”的入口,却忘了订单项还有“删除”入口;但如果你强制所有操作都通过Order聚合根的方法调用:
public class Order { private OrderId id; private OrderStatus status; private List<OrderItem> items; private Money totalAmount; public void changeItemQuantity(ProductId productId, int newQuantity) { if (status == OrderStatus.PAID) { throw new BusinessException("已支付订单不能修改商品数量"); } // 找到对应订单项,更新数量,并同步总金额 OrderItem item = findItem(productId); Money oldSubtotal = item.getSubtotal(); item.changeQuantity(newQuantity); totalAmount = totalAmount.subtract(oldSubtotal).add(item.getSubtotal()); } }这样就确保了“已支付订单不能改数量”这个规则,在任何调用方路径上都不会被漏掉。你甚至不用在Service里重复做这个判断,因为Service根本没有权限跳过changeItemQuantity直接改数量。
3.2 聚合根设计的关键动作:哪些操作放在聚合根上
聚合根里应该放什么样的方法?我的判断标准是:只要这个操作可能破坏聚合内不变量,就必须由聚合根自己来实现。以下动作几乎必须放在聚合根中:
- 创建聚合内部的实体/值对象:比如向订单添加订单项,应由
order.addItem(...)负责,而不是由Service先创建OrderItem再塞进Order。 - 修改聚合内部实体的状态:如修改订单项数量、更新收货地址,都应表现为聚合根的方法。
- 校验并执行删除:比如删除订单项,需要同时考虑状态和金额变动。
- 从现有聚合派生出其他聚合:比如订单确认后,生成出库单,应由
order.confirm()返回OutboundOrder数据,或由领域服务协调。
另外还有一个特别重要的职责:删除操作必须由聚合根控制。实体不能被外界的delete方法随意删除,因为删除同样会破坏不变量。例如订单删除时,必须校验是否处于未支付状态,并同时删除所有订单项。如果允许外部直接删除订单项,那订单总额和订单项的对账关系立刻就被打破了。
3.3 聚合根之间的引用:只引ID不引对象
设计聚合根时有个高频错误:Order聚合根里直接引用Customer对象,然后通过order.getCustomer().getLevel()判断折扣。这会让两个聚合纠缠在一起,事务边界和一致性边界瞬间模糊。
正确的做法是:Order聚合根只持有customerId,需要客户等级时由应用服务调用CustomerRepository获取Customer聚合根,再基于业务规则做决策。也就是说,跨聚合的数据读取可以发生,但跨聚合的写操作不能直接通过对象引用来完成。
这个原则还有个额外好处:它天然逼迫你思考“最终一致性”。订单和客户积分可以分开保存、异步更新,因为Order不直接持有Customer对象,不让一个事务锁住两个聚合。很多线上死锁问题,其实就源于聚合根之间互相引用了对象。
4. 仓库:把持久化细节挡在模型之外
4.1 仓库解决的问题:DAO不是仓库
很多人会把Repository和DAO画等号,这是DDD落地过程中一个常见的误解。DAO(Data Access Object)的核心目的是封装数据库操作,返回的是数据库行映射后的对象(比如OrderDO);仓库(Repository)的核心目的是为领域模型提供聚合根的重建和持久化服务,屏蔽底层存储细节。
两者最大的区别在于:DAO面向数据库,仓库面向聚合根。DAO可以随便selectByUserId、selectByStatus;但仓库接口的粒度通常以聚合根为中心,方法名更贴近业务语义。
传统写法里Service直接依赖OrderMapper,结果就是业务代码里到处是orderMapper.updateByPrimaryKey这类技术动作。如果哪一天订单表从MySQL迁移到MongoDB,或者Redis缓存需要前置,Mapper接口全要变。而仓库把“聚合根 -> 存储”的转换收拢到实现里,业务层只感知OrderRepository:
public interface OrderRepository { Order findById(OrderId orderId); void save(Order order); void delete(Order order); }4.2 仓库接口设计:为什么只围绕聚合根
一个常见的灵魂拷问是:“订单项的增删改查要不要在仓库里给个接口?”我的回答是:不要。理由很简单——如果仓库提供了saveOrderItem、deleteOrderItem这类接口,等于把聚合内部的成员暴露给了外部,聚合边界的防御就被仓库撕开了一个口子。你确实可以保证Service “尽量”不直接调用这些接口,但只要接口存在,就会有人用。
因此我建议把这条定为团队铁律:只有聚合根才有仓库,聚合内部的实体和值对象没有自己的仓库。对订单项的任何访问,都必须走OrderRepository.findById拿到整个Order聚合根,再调用order.addItem或order.changeItemQuantity,最后调orderRepository.save(order)。
一开始大家会觉得别扭,尤其习惯了写OrderItemMapper的人。但一旦适应之后,你会发现业务代码真的变简单了很多——你不再需要考虑先删什么后插什么,只需关心业务意图,持久化细节全在仓库实现里。
4.3 事务边界放哪里:应放在应用服务层
这里有个容易踩的坑:事务注解写在仓库实现里,还是写在Service里?我的经验是——事务一定要放在应用服务层(Application Service),不要放在仓库实现里。原因很简单:一个应用服务方法通常是一个完整的业务用例,它可能要操作多个仓库(比如订单仓库和库存仓库)。如果仓库自己声明了事务,事务边界就过于狭小,无法覆盖整个业务用例。
推荐的做法是让应用服务方法成为事务边界:
public class CheckoutAppService { private final OrderRepository orderRepository; private final ProductRepository productRepository; private final OrderFactory orderFactory; @Transactional public OrderId checkout(CheckoutCommand command) { Order order = orderFactory.create(command); productRepository.deductStock(order.getItemProductIdsWithCounts()); orderRepository.save(order); return order.getId(); } }在这个例子里,@Transactional标注在checkout方法上,保证“扣库存+保存订单”在一个事务里完成。如果事务被放在仓库层,每个仓库方法各自提交,业务用例就会变成多个独立事务,一旦中间出错,数据就会不一致。
4.4 仓库实现的技术细节与性能取舍
很多团队落地仓库时会踩到性能的坑,主要原因是过度封装。如果你在MySQL上实现了仓库,一个findById居然要先把订单头查出来,再循环去查订单项,最后再查地址,那性能肯定惨不忍睹。实际项目中,我更推荐在仓库实现里做“一次查全部”的批量加载:
@Repository public class OrderRepositoryImpl implements OrderRepository { private final JdbcTemplate jdbcTemplate; private final OrderMapper orderMapper; @Override public Order findById(OrderId orderId) { OrderDO orderDO = orderMapper.selectById(orderId.getValue()); List<OrderItemDO> itemDOs = orderItemMapper.selectByOrderId(orderId.getValue()); // 也许还要查 address、promotion 等 return orderAssembler.toAggregate(orderDO, itemDOs, addressDO); } @Override public void save(Order order) { // 全量比对或一条UPDATE语句,符合ORM性能要求 } }这样做表面上是“偷懒”,实际上是合理的。仓库的职责本来就是把聚合根完整重建出来,一次查询加载全部相关数据,符合“把聚合根作为一个整体持久化”的思路。至于N+1查询、懒加载,这些属于实现层面的取舍,不应该让领域层的聚合根感知。
5. 工厂:聚合创建阶段的安全兜底
5.1 为什么聚合根不能总是直接new
把聚合根创建出来这个动作,看起来简单,其实容易被低估。以订单为例,创建一个合法订单要满足多少条件?订单号要生成、初始状态要是待支付、商品项要校验存在、数量要为正数、总金额要重新计算、收货地址要做完整性校验……
如果这些逻辑全写在聚合根的构造函数里,构造函数就会异常臃肿;如果全写在应用服务里,应用服务会退化成“创建过程的事务脚本”。更重要的是,构造函数没法表达“创建订单”过程中依赖外部资源(比如校验商品价格、检查库存)的逻辑。这时就需要**工厂(Factory)**出场。
5.2 工厂的定位:把复杂创建过程封装成一个业务动作
DDD中的工厂与GoF设计模式里的“工厂方法/抽象工厂”不完全是一回事。DDD里的工厂核心作用是:封装将多个数据源或参数转换成聚合根实例的复杂逻辑,并确保创建完成的聚合根处于有效状态。
我当时在项目里落地工厂时,踩过一个特别深的坑:刚开始,我把校验逻辑写在应用服务里,创建完订单后却发现有些校验根本不完整。后来我干脆把“创建订单”相关的校验全部收进OrderFactory.create,应用服务只管获取外部依赖并委托给工厂。这样创建的Order从出生起就是合法的,后续所有方法都能安全依赖“订单已经具备基本合法性”这个前提。
@Component public class OrderFactory { private final ProductRepository productRepository; public Order create(CheckoutCommand command) { // 1. 生成订单号等基础值对象 OrderId orderId = OrderId.generate(); // 2. 通过商品仓库获取商品快照,校验商品是否可售 List<ProductSnapshot> products = productRepository.findSnapshots(command.getItemProductIds()); if (products.size() != command.getItemCount()) { throw new BusinessException("部分商品不存在"); } // 3. 构建订单项列表,并校验数量 List<OrderItem> items = new ArrayList<>(); for (CheckoutCommand.Item cmd : command.getItems()) { ProductSnapshot product = findProduct(cmd.getProductId()); items.add(OrderItem.create(product.toSnapshot(), cmd.getQuantity())); } // 4. 计算总额,校验最低金额/优惠 Money total = calculateTotal(items, command.getCoupon()); // 5. 返回一个状态合法、不变量自洽的 Order 聚合根 return new Order(orderId, items, total, OrderStatus.PENDING_PAYMENT); } }注意:工厂不等于“万能构造器”。如果创建逻辑很简单,比如
new Address(city, street, zipCode),直接在聚合根里完成即可,没必要再套一层工厂。只有创建流程复杂、依赖外部数据或涉及多个实体/值对象组装时,才值得引入工厂。
5.3 工厂与构造函数、Builder之间的分工
很多项目里会出现OrderBuilder,然后Service代码里new OrderBuilder().items(...).address(...).build(),看起来也能解决创建问题。但Builder本质上是“工具”,不承载业务校验;它可以控制必填字段的完整性,但无法执行类似“查商品价格并计算总额”的业务动作。所以我的习惯是:
- 值对象/简单实体的创建:用构造函数或静态工厂方法,比如
OrderItem.create(...)。 - 聚合根的复杂创建:用领域工厂(Factory),负责依赖外部数据与组装。
- DTO/命令对象到聚合根的转换:用Assembler/Converter,放在基础设施层或应用层。
这三者各有分工,不要混用。如果一股脑全用Builder,最后最可能的结果是:所有人都拿着Builder绕过聚合根的默认约束,Builder直接变成了又一个setter集合。
5.4 创建过程中的校验到底谁负责
很多团队会在“校验到底放哪”这个问题上反复拉扯。我的建议是分三层:
- 输入格式校验(如订单项数量是否为0、商品ID是否为空):放在应用服务的入参校验中,通常用Bean Validation即可。
- 业务规则校验(如商品是否上架、库存是否充足、是否超过限购数量):放在工厂创建聚合根的过程中,或交给领域服务。
- 聚合状态校验(如已支付订单不能修改数量):放在聚合根的方法内部。
这样分层最直观的好处是:每一层只需关心本层职责,不会出现应用服务里又写业务规则又写输入校验的混乱局面。聚合根一旦被创建出来,其内部状态就必须是可信的,后续的所有防御都建立在“可信状态”之上。
6. 四者协同防御:一次完整下单流程的解释
前面分别讲了聚合、聚合根、仓库和工厂,但如果你只在代码里看到它们各自出现,还是会觉得零散。这部分我按真实的下单场景,把它们串成一个完整流程,让你看到它们如何一层层协作。
6.1 一次完整下单的代码流转
假设用户在前端提交了一个下单请求,后端入口是Controller,往下依次经过应用服务、工厂/聚合根、仓库、基础设施层。配合代码看会更直观:
@RestController public class OrderController { private final CheckoutAppService checkoutAppService; @PostMapping("/orders") public OrderResponse checkout(@RequestBody @Valid CheckoutRequest request) { OrderId orderId = checkoutAppService.checkout(request.toCommand()); return OrderResponse.from(orderId); } }Controller层做的只是接收请求、参数校验、返回响应,没有任何业务逻辑。
public class CheckoutAppService { private final OrderFactory orderFactory; private final OrderRepository orderRepository; private final ProductRepository productRepository; @Transactional public OrderId checkout(CheckoutCommand command) { // 1. 工厂:创建合法订单聚合根 Order order = orderFactory.create(command); // 2. 聚合根:执行业务动作并维护不变量 order.place(); // 更新状态、校验支付时限、生成本地时间戳等 // 3. 仓库:持久化聚合根,以整体为单位保存 orderRepository.save(order); return order.getId(); } }这段代码看起来很短,但它实际上把三类防御责任分得非常清楚:
- 工厂负责“创建一个从出生就合法的订单”。
- 聚合根负责“订单状态流转时的业务规则”。
- 仓库负责“让订单聚合根可以整体持久化和重建”。
- 应用服务只负责编排事务边界,不做业务判断。
6.2 聚合根内部如何协作:以修改订单项为例
现在我们再往深一层看,聚合根内部是如何协调各实体的。假设用户下单后修改某个商品数量:
public class Order { private final List<OrderItem> items; private Money totalAmount; private OrderStatus status; public void changeItemQuantity(ProductId productId, int newQuantity) { // 防御第一条:状态校验 if (status == OrderStatus.PAID) { throw new BusinessException("已支付订单不能修改商品数量"); } // 防御第二条:找到目标订单项 OrderItem item = findItem(productId); if (item == null) { throw new BusinessException("该商品不在订单中"); } // 防御第三条:同步更新总金额,维持不变量 Money oldSubtotal = item.getSubtotal(); item.updateQuantity(newQuantity); Money newSubtotal = item.getSubtotal(); totalAmount = totalAmount.subtract(oldSubtotal).add(newSubtotal); } }这里最核心的防御其实是第三条:总金额必须在聚合根内部同步更新。如果外部代码自己改数量后自己去算总金额,只要有一次遗漏,总金额和明细就散架了。而changeItemQuantity这个方法把所有该做的动作都包在一起,无需外部操心。
6.3 仓库在这里的“防御”体现在哪
如果你让我挑一个最容易写坏的层,我会选仓库。仓库很容易退化成“很厚的DAO”,从而绕过聚合根的约束。为了防住这条线,我总结了一个“仓库防御自查表”,每次评审代码都会照着看:
- 仓库接口的返回值是否都是聚合根类型,而不是内部实体类型?
- 仓库接口的方法名是否偏业务语义,而不是CRUD语义?
- 仓库实现是否负责“聚合根 <-> 存储模型”的转换?
- 仓库是否承担了事务边界的声明?如果承担了,应移到应用服务层。
- 是否有人能绕过聚合根,直接通过仓库拿到
OrderItem并修改?如果存在,需要裁掉那个接口。
如果这五条都守住,那么仓库就会成为聚合根的一道保护墙,而不是另一个破洞。
7. 实战中的常见问题与排查技巧
7.1 聚合根变“上帝类”,一改就崩
最常见的失败模式是:一开始精心划分聚合,但后来业务新需求不断出现,把方法不停往聚合根上堆,最终聚合根变成引用了十几个仓库和服务的“上帝类”。这个问题的主要诱因是“聚合根里什么操作都想管”。
我的排查和修正思路是:
- 如果聚合根里的方法要引用多个外部仓库(比如商品仓库、库存仓库、用户仓库),先怀疑它是不是“应用服务的逻辑误入了聚合根”。
- 聚合根只应操作自己聚合内部的对象,不直接操作外部聚合。如果需要跨聚合协调,应通过领域服务或应用服务编排。
- 如果聚合根确实因为业务复杂度变大,优先考虑拆聚合,而不是让聚合根无限膨胀。比如“订单”聚合和“订单配送”聚合如果生命周期不同步,就应该拆开,只通过
orderId关联。
7.2 仓库返回List<Order>导致“聚合根失效”
很多同事写查询时会觉得“查询又不是修改,直接返回实体不要紧”。这构成了一个危害性很大的习惯:如果仓库提供一个findByUserId(Long userId)返回List<Order>,但同时把Order内部的items也一起返回,那调用方就能绕过order.changeItemQuantity,直接对items做操作。
我曾经在项目里遇到底层同事为了页面列表展示方便,直接在Order聚合根里写了一个getItems(),然后在页面渲染时手动计算商品数量。结果某次运营直接调用了一个后台接口修改了订单项数据,导致总金额对不上,排查了半天才发现是“直取聚合内部结构”惹的祸。
要避免这种问题,我有两个建议:
- 列表查询,不要返回聚合根。如果需要展示订单列表,就定义单独的只读DTO/QueryModel,由专门的查询服务负责,仓库只做聚合根的写操作重建。
- 聚合根内部数据,能不暴露就不暴露。实在需要提供给应用服务做展示,可以返回不可变对象或深拷贝,避免外部直接修改。
7.3 “跨聚合事务”的迷思:到底能不能跨聚合更新
前面我反复强调“一个事务里最好只更新一个聚合”,但实际业务里经常会遇到“必须同时更新订单和库存”的场景。这时很多人会为了省事,直接让订单聚合根持有商品仓库引用,在一个事务里同时操作两个聚合。
我的建议是:可以,但要在应用服务层编排,而不是让聚合根越界。如果业务确实强一致要求很高(比如下单必须扣库存,且不能有延迟),就在应用服务层标记@Transactional,一次提交两个仓库。但如果可以接受最终一致,优先用领域事件,下单成功后发布OrderPlacedEvent,由订阅方异步扣减库存。
判断标准是业务语言里的“一致性等级”。如果业务上允许“下单成功后几秒再扣库存”,就不要硬塞进同一个事务。DDD的核心不是禁止跨聚合事务,而是把是否跨聚合这个决策摆到明面上来,而不是下意识地在聚合根里引入一堆外部依赖。
7.4 懒加载与N+1:实现不当导致性能崩塌
仓库实现如果基于JPA/Hibernate,新手最喜欢把“懒加载”直接塞进领域模型里,让Order聚合根内部的List<OrderItem>在访问时才从数据库查询。但这样会让聚合根与数据库Session耦合,一旦应用服务将聚合根返回给上层,Session一关,再访问内部实体就报错。
我的经验是:聚合根内部的对象图应该是完整的内存对象,不要依赖懒加载。在仓库实现里,用一次连接把聚合根所需的数据全部加载出来,组装成内存对象后返回。如果数据量大,可以做分页查询、减少单次加载的深度,但不要把懒加载带到领域层。
这时候使用MyBatis这类半自动ORM反而更直观——你可以自己写联表查询,一次性把订单头和订单项都查出来,再组装成聚合根,不需要理会懒加载代理问题。如果你一定要用JPA,也建议仓库实现中显式join fetch保证聚合根完整加载,然后关闭Session。
8. 协同防御落地:几个可以立即执行的检查动作
模型设计是否合理,最终还是看代码能不能稳住。分享几个我在项目评审和重构时必做的检查动作,如果你正打算把现有代码往DDD方向调整,可以照此执行。
8.1 先找出“破窗”的setter
第一步,在代码库里全局搜索聚合根实体类的set方法。如果一个聚合根对外暴露了大量public setter,比如setTotalAmount、setStatus、setItems,那么这个聚合根基本上处于“裸奔”状态。执行动作是:
- 把对外setter改成包内可见或private。
- 对每个被调用方,分析其业务意图,替换成聚合根内的业务方法。
- 如果你发现有些调用方仅仅是为了“保存数据”而设置状态,说明应用层的设计还是事务脚本风格,需要进一步收口。
这一步往往是整个重构里工作量最大但收益最高的。
8.2 检查仓库接口,删除“实体级”CRUD
第二步,检查所有仓库接口,如果发现OrderItemRepository、AddressRepository这类“实体仓库”,要想办法把它们裁掉。保留的仓库应该是对齐聚合根的,比如OrderRepository、CustomerRepository、ProductRepository。
判断一个仓库是否属于实体级CRUD,可以看方法名:如果方法名大多是save、delete、findById、findAll,且根本没有聚合根概念,那很可能就是旧DAO习惯的残留。
8.3 用“不变量测试”守住院子
防御体系最终靠代码Review和测试来巩固。我特别建议为不变量写测试,而不是只测试“CRUD能跑通”。
以下单场景为例,至少要写这样的测试用例:
@Test void 已支付订单修改数量应抛异常() { Order order = orderFactory.create(validCommand()); order.pay(); assertThrows(BusinessException.class, () -> order.changeItemQuantity(productId, 2)); } @Test void 修改数量后总金额应自动更新() { Order order = orderFactory.create(validCommand()); Money oldTotal = order.getTotalAmount(); order.changeItemQuantity(productId, 3); assertNotEquals(oldTotal, order.getTotalAmount()); assertEquals(calculateExpectedTotal(order), order.getTotalAmount()); }这类测试直接锁定“不变量”,只要有人试图绕过聚合根逻辑,测试就会报警。团队里多写几张这样的测试网,比在Code Review时反复强调“别绕过聚合根”有效得多。
8.4 把“为什么”写进代码注释
最后一条看似偏“软”,但我认为非常重要。聚合、聚合根、仓库、工厂这些概念单独看都能理解,但团队协作时很容易迷路,因为代码里很少解释“为什么这个设计长这样”。
例如聚合根里的changeItemQuantity方法,如果只写“修改数量”,下次接手的同事很可能觉得“我直接加一个setter调一下不更快吗”。但如果写上注释:“状态为已支付时不得修改商品数量;修改数量必须同步计算总金额”,后来者就知道这个方法背后的业务约束是什么了。不需要写长篇大论,一两句“为什么”就足够。
在我自己带团队做领域模型重构时,一个非常明显的规律是:哪些模块把不变量解释清楚了,哪些模块后续改动就越少出问题;哪些模块只贴了一堆结构性注解,哪些模块隔三差五就出现线上漏洞。代码的防御力,本质上靠的是思维的防御力——你有多清楚自己在守什么,代码就有多经得起考验。
我个人在实际操作中还有个体会:DDD这套东西不能“一步到位”。你让一个习惯于事务脚本的团队一夜之间全部改成聚合根+仓库,大概率会溃败。更好的方式是先挑一个业务规则密集、变更频繁的模块(比如订单、结算、库存)做试点,把聚合边界和仓库防御打扎实,跑一两个迭代,尝到甜头后,再逐步推广到其他模块。这样团队对新风格的接受度会高很多,你也能在试点过程里积累出真正适合自己业务的“防御清单”。希望这篇文章能帮你把DDD从“概念名词”变成“代码里实实在在的防线上限”。