news 2026/9/9 10:49:40

DDD防御体系:聚合、聚合根、仓库与工厂如何守住院子

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DDD防御体系:聚合、聚合根、仓库与工厂如何守住院子

你不妨回想一下,自己有没有在接手一个陈旧业务系统时,做过这样的重构:把一个几百行的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); } }

这段代码看起来没毛病,很多人每天都在写。但如果仔细看,你会发现在这里,OrderDOOrderItemDO只是数据的搬运工,所有业务决策都在Service里做。我们称之为贫血模型

贫血模型最大的问题不是“实体没有方法”,而是:业务规则不归属任何实体,它们散落在Service里,完全依赖程序员的“自觉”来调用。今天你记得在addItem里校验数量为正数,明天别人新增了batchAddItems,未必会记得同样的校验。结果就是:同一个业务规则,有的入口守住了,有的入口漏了。

1.2 不变量——领域模型最需要保护的东西

领域模型里最核心的东西不是属性,而是不变量(Invariant)。不变量的意思是“无论外部怎么操作,这个业务规则必须始终保持成立”。举几个典型例子:

  • 订单的totalAmount必须等于所有orderItemsubtotal之和。
  • 已支付的订单不能再新增商品项。
  • 下过单的客户不允许被直接删除。
  • 商品库存不能为负数。

这些不变量一旦被破坏,无论代码跑得多流畅,业务上都已经是错的。贫血模型的问题在于,它用“setter+外部计算”的方式表达数据变化,等于把不变量的维护责任全推给了Service层。而Service层又是最容易膨胀、最容易被绕过的地方,自然守不住。

所以DDD的第一性原理就浮现出来了:把和不变量相关的数据和行为收拢到一个封闭的边界内部,让外部只能通过这个边界的门面(聚合根)来触发变化。这就是聚合和聚合根存在的根本理由。

2. 聚合边界:领域模型的第一道防线

2.1 聚合到底是什么:一起变化的数据和行为

在我讲过的很多案例里,用“同事关系”来理解聚合是最快的方式。你在一家公司里,一个项目组成员通常会一起协作、一起被考核、一起变动,项目组内部沟通频繁;而不同项目组之间,只通过项目经理或接口人对接,不会直接互相指挥。

聚合就是领域里的“项目组”。它把一组在生命周期上强相关、在业务规则上互为约束的对象(实体、值对象)组合在一起,并指定一个对象作为唯一对外的接口(聚合根)。

回到下单场景,订单、订单项、收货地址、金额明细这些对象应当被划进同一个聚合,叫Order。为什么?因为:

  • 订单项的增删必须同步影响订单总额(totalAmount和 所有itemssubtotal存在不变量);
  • 收货地址可能被修改,但删除订单时,订单项和地址必须一起删除;
  • 对订单项的任何变化,都必须先经过订单这个整体来决策。

这些强约束说明它们之间不是“弱关联”,而是同一个生命周期。强行拆开只会把不变量暴露到一个很广的范围,增加被破坏的概率。

2.2 聚合边界怎么划:从不变量反推范围

很多团队上手DDD第一件事就是“划分聚合”,然后一群人对着业务图争执。实际上,聚合边界不是靠“感觉”划的,而是靠不变量反推的。具体思考步骤大概是:

  1. 先找出领域里最重要的业务规则/不变量。
  2. 列出满足这些不变量需要哪些数据。
  3. 这些数据的变化频率和变化原因是强绑定,还是各自独立?
  4. 如果两个对象只有“读取”关联,没有“写一致性”要求,就别放在同一个聚合里。

有人会反问:“订单和订单项,商品和库存,是不是都应该拆开?”我的经验是:先看事务边界。如果两个对象必须在一个数据库事务里保持强一致,它们大概率应该落在同一个聚合里;如果允许最终一致(比如下完单后异步扣库存),它们就分别属于不同的聚合。

注意:聚合不是越大越好。很多团队最初把订单、商品、库存、优惠券全塞进一个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不是仓库

很多人会把RepositoryDAO画等号,这是DDD落地过程中一个常见的误解。DAO(Data Access Object)的核心目的是封装数据库操作,返回的是数据库行映射后的对象(比如OrderDO);仓库(Repository)的核心目的是为领域模型提供聚合根的重建和持久化服务,屏蔽底层存储细节。

两者最大的区别在于:DAO面向数据库,仓库面向聚合根。DAO可以随便selectByUserIdselectByStatus;但仓库接口的粒度通常以聚合根为中心,方法名更贴近业务语义。

传统写法里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 仓库接口设计:为什么只围绕聚合根

一个常见的灵魂拷问是:“订单项的增删改查要不要在仓库里给个接口?”我的回答是:不要。理由很简单——如果仓库提供了saveOrderItemdeleteOrderItem这类接口,等于把聚合内部的成员暴露给了外部,聚合边界的防御就被仓库撕开了一个口子。你确实可以保证Service “尽量”不直接调用这些接口,但只要接口存在,就会有人用。

因此我建议把这条定为团队铁律:只有聚合根才有仓库,聚合内部的实体和值对象没有自己的仓库。对订单项的任何访问,都必须走OrderRepository.findById拿到整个Order聚合根,再调用order.addItemorder.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,比如setTotalAmountsetStatussetItems,那么这个聚合根基本上处于“裸奔”状态。执行动作是:

  • 把对外setter改成包内可见或private。
  • 对每个被调用方,分析其业务意图,替换成聚合根内的业务方法。
  • 如果你发现有些调用方仅仅是为了“保存数据”而设置状态,说明应用层的设计还是事务脚本风格,需要进一步收口。

这一步往往是整个重构里工作量最大但收益最高的。

8.2 检查仓库接口,删除“实体级”CRUD

第二步,检查所有仓库接口,如果发现OrderItemRepositoryAddressRepository这类“实体仓库”,要想办法把它们裁掉。保留的仓库应该是对齐聚合根的,比如OrderRepositoryCustomerRepositoryProductRepository

判断一个仓库是否属于实体级CRUD,可以看方法名:如果方法名大多是savedeletefindByIdfindAll,且根本没有聚合根概念,那很可能就是旧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从“概念名词”变成“代码里实实在在的防线上限”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 10:47:59

灵活性供需不确定下储能配置的Matlab双层优化建模思路

1. 为什么要在这个时间点重新审视“灵活性供需不确定”下的储能配置过去几年我做过不少储能配置项目&#xff0c;早期大家关注的核心是“新能源弃电率”和“最大需量削减”&#xff0c;目标函数基本围绕这两个指标打转。但这两年随着各地新能源渗透率快速攀升&#xff0c;电网调…

作者头像 李华
网站建设 2026/9/9 10:47:46

2026年固态硬盘选购指南:主控、颗粒与品牌全面解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 10:47:09

opencode不是工具,而是开发环境误操作的典型符号

1. “opencode”到底是什么&#xff1f;别再被热搜词带偏了&#xff0c;它根本不是开源项目或AI编码工具 最近刷技术社区、知乎、V2EX甚至小红书&#xff0c;总能看到“opencode”这个词高频出现——和 npm、Homebrew、VS Code、ARM 头文件报错混在一起&#xff0c;标题动辄是《…

作者头像 李华
网站建设 2026/9/9 10:47:07

告别灯具驱动反复损坏!直流照明对比交流照明优势在哪

适用场景&#xff1a;工业园区生产厂房 随着绿色建筑、零碳建筑理念不断普及&#xff0c;建筑照明不再只追求亮度达标&#xff0c;供电架构的革新&#xff0c;正在成为建筑降本增效的关键抓手。工业园区生产厂房空间大、照明时长久&#xff0c;很多工程从业者都在纠结&#xff…

作者头像 李华
网站建设 2026/9/9 10:46:01

双足机器人源码实战:从代码结构到仿真实物调试全攻略

简介&#xff1a;基于STM32的双足机器人竞赛源码&#xff0c;源自省级比赛中荣获一等奖的实际项目&#xff0c;适合嵌入式开发者、机器人爱好者及电子设计竞赛学生参考&#xff0c;重点解决双足行走控制、传感器数据处理与电机驱动等工程实现问题。压缩包内共有438个文件&#…

作者头像 李华
网站建设 2026/9/9 10:45:11

C++23 assume属性:编译器优化利器与工程落地实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华