资讯详情

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

📅 2026/9/9 10:49:47 | 华诺云谱 👁 阅读
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); } }这段代码看起来没毛病很多人每天都在写。但如果仔细看你会发现在这里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 ListOrderItem 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落地过程中一个常见的误解。DAOData Access Object的核心目的是封装数据库操作返回的是数据库行映射后的对象比如OrderDO仓库Repository的核心目的是为领域模型提供聚合根的重建和持久化服务屏蔽底层存储细节。两者最大的区别在于DAO面向数据库仓库面向聚合根。DAO可以随便selectByUserId、selectByStatus但仓库接口的粒度通常以聚合根为中心方法名更贴近业务语义。传统写法里Service直接依赖OrderMapper结果就是业务代码里到处是orderMapper.updateByPrimaryKey这类技术动作。如果哪一天订单表从MySQL迁移到MongoDB或者Redis缓存需要前置Mapper接口全要变。而仓库把“聚合根 - 存储”的转换收拢到实现里业务层只感知OrderRepositorypublic 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()); ListOrderItemDO itemDOs orderItemMapper.selectByOrderId(orderId.getValue()); // 也许还要查 address、promotion 等 return orderAssembler.toAggregate(orderDO, itemDOs, addressDO); } Override public void save(Order order) { // 全量比对或一条UPDATE语句符合ORM性能要求 } }这样做表面上是“偷懒”实际上是合理的。仓库的职责本来就是把聚合根完整重建出来一次查询加载全部相关数据符合“把聚合根作为一个整体持久化”的思路。至于N1查询、懒加载这些属于实现层面的取舍不应该让领域层的聚合根感知。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. 通过商品仓库获取商品快照校验商品是否可售 ListProductSnapshot products productRepository.findSnapshots(command.getItemProductIds()); if (products.size() ! command.getItemCount()) { throw new BusinessException(部分商品不存在); } // 3. 构建订单项列表并校验数量 ListOrderItem 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 ListOrderItem 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 仓库返回ListOrder导致“聚合根失效”很多同事写查询时会觉得“查询又不是修改直接返回实体不要紧”。这构成了一个危害性很大的习惯如果仓库提供一个findByUserId(Long userId)返回ListOrder但同时把Order内部的items也一起返回那调用方就能绕过order.changeItemQuantity直接对items做操作。我曾经在项目里遇到底层同事为了页面列表展示方便直接在Order聚合根里写了一个getItems()然后在页面渲染时手动计算商品数量。结果某次运营直接调用了一个后台接口修改了订单项数据导致总金额对不上排查了半天才发现是“直取聚合内部结构”惹的祸。要避免这种问题我有两个建议列表查询不要返回聚合根。如果需要展示订单列表就定义单独的只读DTO/QueryModel由专门的查询服务负责仓库只做聚合根的写操作重建。聚合根内部数据能不暴露就不暴露。实在需要提供给应用服务做展示可以返回不可变对象或深拷贝避免外部直接修改。7.3 “跨聚合事务”的迷思到底能不能跨聚合更新前面我反复强调“一个事务里最好只更新一个聚合”但实际业务里经常会遇到“必须同时更新订单和库存”的场景。这时很多人会为了省事直接让订单聚合根持有商品仓库引用在一个事务里同时操作两个聚合。我的建议是可以但要在应用服务层编排而不是让聚合根越界。如果业务确实强一致要求很高比如下单必须扣库存且不能有延迟就在应用服务层标记Transactional一次提交两个仓库。但如果可以接受最终一致优先用领域事件下单成功后发布OrderPlacedEvent由订阅方异步扣减库存。判断标准是业务语言里的“一致性等级”。如果业务上允许“下单成功后几秒再扣库存”就不要硬塞进同一个事务。DDD的核心不是禁止跨聚合事务而是把是否跨聚合这个决策摆到明面上来而不是下意识地在聚合根里引入一堆外部依赖。7.4 懒加载与N1实现不当导致性能崩塌仓库实现如果基于JPA/Hibernate新手最喜欢把“懒加载”直接塞进领域模型里让Order聚合根内部的ListOrderItem在访问时才从数据库查询。但这样会让聚合根与数据库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从“概念名词”变成“代码里实实在在的防线上限”。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。