1. 聚合边界往哪条线上画:从业务不变量反推,而不是从表关系反推
先讲一个我实际经手过的项目。业务方提了一个需求:订单总价超过2万元时,不允许再叠加第二张优惠券。听起来很简单对吧?结果我打开代码一看,下单入口在应用服务里塞了一大段逻辑,先查订单,再查全部优惠券,然后在for循环里判断总价、判断优惠券类型、判断是否可叠加,最后写回数据库。这套规则确实能用,但问题来了:三个月后另一个团队要写一个“后台补单”功能,他们没有走同样的入口,直接在订单表里插了一条数据,优惠券规则完全没生效。领导问起来,两边互相甩锅。
这就是典型的领域模型失守:规则是写对了,但规则散落在各个入口,没有一个结构性的约束让它“只能这么走”。
1.1 不变量才是聚合边界的唯一裁判
聚合(Aggregate)在DDD里的本质,不是一组对象的集合,而是一个业务不变量的保护壳。不变量就是业务上任何时刻都必须成立的条件。刚才说的“总价超过2万不能叠加第二张优惠券”,就是一个不变量。
判断哪些对象属于同一个聚合,标准不是“它们有没有外键关系”,也不是“它们是不是同一张表的数据”,而是:当你修改某个对象时,还有哪些对象的数据必须和它保持同步、保持一致,否则业务就会出错? 这些必须同步的对象,就应该被划进同一个聚合里。
比如“订单行”和“订单头”。一个订单有多少个订单行、订单行属于哪个订单,这个信息如果分散处理,很容易出现账单上加了一个商品,但总价没更新的情况。所以订单头和订单行天然属于同一个聚合,聚合根是订单头。再比如“购物车”和“购物车项”,同理。
但“用户”和“订单”要不要在一个聚合里?通常不需要。因为用户资料变了,不影响订单的金额和商品明细;订单状态变了,也不影响用户的基本信息。它们之间最多是引用关系,用一个userId关联就够了。强行把用户放进订单聚合,只会让聚合变得巨大,每次下单都要锁定用户记录,性能直线下降。
1.2 判断聚合大小的两个实用问题
我画聚合边界的时候,一般问自己两个问题:
第一个问题:如果这两类对象之间的数据出现不一致,业务上能不能容忍?如果不能容忍、必须强一致,那就在一个聚合里;如果允许短暂不一致、后续通过补偿来修正,那就拆成两个聚合,靠领域事件或最终一致性来串联。
第二个问题:一个事务里最多要锁几张表?DDD的一条铁律是一个事务只修改一个聚合。假如你的聚合边界画得太粗,一个订单聚合里塞了用户、地址、库存、支付记录,那一次下单要锁四五张表,并发一高就死锁、超时、性能崩盘。反过来画得太细,一次下单要开好几个事务,数据一致性又没法保证。
我的经验是,一个聚合包含的对象控制在3到5个以内比较合理。超过5个,先停下来看看是不是把不相干的东西塞进来了。以下是我经常用来衡量聚合设计的自查表:
| 信号 | 大概率是聚合过粗 | 大概率是聚合过细 |
|---|---|---|
| 一次操作要更新的表 | 4张以上 | 每张表一个聚合,互相用外键引用 |
| 一致性要求 | 真正需要强一致的很少,大部分可以最终一致 | 所有对象都要求强一致,但被拆开了 |
| 锁的范围 | 一个事务锁太多表,并发上不去 | 一个事务拆成多个,中间崩了没法回滚 |
| 修改入口 | 同一个业务操作要跨多个聚合才能完成 | 同一个聚合里的数据,被外界直接用ID改掉了 |
这些信号没法用自动化工具检测,只能靠人对业务的理解。所以DDD落地难,难的不是学概念,而是判断边界。
1.3 聚合边界常见的两个画法坑
第一个坑:按数据模型画。很多团队是从表结构反推聚合的,外键关联紧密就放一起,没外键就拆开。这完全反了。表结构是持久化层面的设计,聚合是业务层面的设计。你完全可以在数据库里建五张表,但业务上只有一个聚合;也可以一张表都没有,聚合里只有内存对象。先定聚合,再设计表结构,顺序不能反。
第二个坑:按“操作便利性”画。有人觉得“反正一个下单接口里要查这么多数据,干脆全塞一个聚合里,省得跨聚合调用”。这种心态是灾难的开始,因为聚合越大,并发冲突的概率越大,一致性检查的开销越高,最终系统会卡在数据库锁上。
聚合边界划定了,接下来最核心的问题就是:边界内部,谁说了算?这就轮到聚合根上场了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 聚合根:用一个“对外唯一入口”把内部规则彻底关起来
聚合根(Aggregate Root)是整个聚合唯一允许被外部直接引用的对象。外部代码想碰聚合里的任何数据,不管是查还是改,都必须通过聚合根。
为什么必须这样?你看一个没有聚合根的模型长什么样:
java复制// 反面示例:任何地方都能自由修改订单行
public class OrderLine {
private BigDecimal price;
private int quantity;
public void setPrice(BigDecimal price) { this.price = price; }
public void setQuantity(int quantity) { this.quantity = quantity; }
}
业务说“订单总价超过2万不能叠券”,你打开项目一搜,发现有十几个地方在调用orderLine.setPrice(...)。每个调用点都要记得自己写一遍校验逻辑,只要漏掉一个,规则就破了。把规则放进setter里?那只是把这个负担从调用点转移到了实体内部,而且实体没法校验“当前这笔改动会不会导致整单总价超标”,因为它看不到别的订单行。
2.1 聚合根真正要守住的,是“整组对象一起变”的规则
聚合根的职责不是把内部属性用private封起来那么简单——那叫封装,任何学过Java的人都会。聚合根的价值在于,它知道聚合内所有对象的状态,能执行那些跨对象的不变量校验。
回到订单的例子,聚合根大概是这样的:
java复制public class Order {
private OrderId id;
private Money totalAmount;
private List<OrderLine> lines;
private List<Discount> discounts;
// 外部唯一能调用的方法:添加订单行
public void addLine(Product product, int quantity) {
// 业务规则1:行数不能超过50
if (this.lines.size() >= 50) {
throw new BusinessException("订单行不能超过50条");
}
// 业务规则2:启动状态不能改单
if (this.status == Status.PAID) {
throw new BusinessException("订单已支付,不能改动");
}
OrderLine line = new OrderLine(product, quantity);
this.lines.add(line);
// 更新总价——注意,总价是聚合根维护的,不靠外界手动算
this.totalAmount = this.totalAmount.add(line.subtotal());
// 业务规则3:总价上限100万
if (this.totalAmount.isGreaterThan(new Money(1_000_000))) {
throw new BusinessException("订单总价不能超过100万");
}
}
public void applyDiscount(Discount discount) {
this.checkCanChange();
// 业务规则4:总价超过2万不能叠加第二张优惠券
if (this.totalAmount.isGreaterThan(new Money(20_000))
&& this.discounts.size() >= 1) {
throw new BusinessException("总价超过2万时,最多只能使用1张优惠券");
}
this.discounts.add(discount);
this.totalAmount = this.totalAmount.subtract(discount.amount());
}
public List<OrderLine> getLines() {
return Collections.unmodifiableList(this.lines);
}
}
看到没有:addLine和applyDiscount内部把所有校验都做完了,外部调用方只管把参数传进来,不需要自己判断“状态是不是PAID”“总价会不会超”“券能不能叠”。这些规则只存在于聚合根里,写一次,所有入口共享。
这个模式叫“命令式修改”,外部对内部说“我要做什么”,而不是“你把这个字段改成什么”。很多团队从传统MVC转DDD时,最拧巴的就是这个转变。他们习惯说“把status设置为1”,改完之后业务规则对不对是调用方的事;现在改成“调用order.confirm()”,规则对不对由订单自己负责。
2.2 getter返回集合时,一定要挡一层
有个细节很多人栽过跟头:聚合根里如果暴露了getLines()直接返回内部List,外部调用方完全可以order.getLines().add(...)——即使你加了private,集合本身还是可以被修改的。这就是我为什么要用Collections.unmodifiableList()包一层。
还有一个更隐蔽的问题:如果外部拿到了订单行对象,然后调orderLine.setPrice(...)。虽然订单行是聚合内的实体,但引用泄露出去之后,别人还是能绕过聚合根直接改它。解决办法有两个:一是订单行内部的setter尽量不给外部用,改成包级私有;二是返回的时候返回快照、副本或者只读View对象。具体用哪种,看团队习惯,我倾向返回只读视图,因为每次拷贝会有性能开销,而只读视图没有。
聚合根的代码写对了,紧接着要解决一个问题:外部代码怎么拿到这个聚合根? 直接new一个吗?不行,因为new出来的对象是游离的,不在仓库的管理范围内,而且创建过程本身可能有一堆复杂逻辑。这就要讲到仓库这个角色了。
3. 仓库的门禁设计:只提供“聚合根级存取”,不让实体裸奔
很多人对仓库(Repository)的理解就是“用来查数据的”,觉得它跟DAO、Mapper差不多,无非是包了一层。这个理解正是DDD走上歪路的起点。
仓库和DAO有本质区别。DAO是面向表的,它天然提供一个泛化的存取能力——selectByOrderId、updateStatus、deleteById。每个表都能配一个DAO,粒度是“一张表”。仓库是面向聚合的,它的粒度是“一个聚合根”,而且它只对聚合根开放,聚合里的其他实体不配拥有自己的仓库。
3.1 为什么聚合内其他实体不允许有自己的仓库
假设你给OrderLine也建了一个Repository,那就等于向全公司宣布:任何代码都可以绕过Order,直接查出一条OrderLine,改掉它的数量,再存回去。这不就回到了规则散落的老路吗?
不给OrderLine建Repository,是结构性的强迫:即使某个人偷懒或者不熟悉DDD,他也没有现成的路可以单独改订单行。想改,就必须拿到Order聚合根,从头开始调用聚合根的方法。我在好几个项目里都验证过,这种“物理隔离”比代码评审里反复强调“不要绕过聚合根”有效得多。
所以,记住这条铁律:每个聚合只有一个Repository,Repository只服务聚合根,不服务聚合内的普通实体。
3.2 仓库接口要像聚合根一样设计,而不是像表操作一样设计
一个典型的、不合格的仓库接口长这样:
java复制public interface OrderRepository {
Order findById(Long id); // 可以
List<OrderLine> findLinesByOrderId(Long orderId); // 不可以!这等于给OrderLine开了后门
void updateStatus(Long id, String status); // 不可以!绕过聚合根改状态
void updateLineQuantity(Long lineId, int qty); // 绝对不可以!
}
findLinesByOrderId和updateStatus这两个方法,一个破坏了聚合封装的边界,一个绕过了订单内部的状态机校验。合格的仓库接口应该是这样的:
java复制public interface OrderRepository {
Order findById(OrderId id);
void save(Order order);
// 如果要按条件查,返回的是Order聚合根集合,而不是OrderLine集合
List<Order> findPendingOrders(int maxResults);
void delete(Order order);
}
看到区别了吗?仓库的入参和出参都应该是聚合根。它不关心Order内部到底有哪些字段、哪些行,那是持久化框架的事情。仓库的意义在于,给你一个“从数据库里拿回一个完整聚合”的通道,以及“把聚合完整保存回去”的通道。
我见过一个常见的坏味道:有人为了“性能”,在仓库里加一个findOrderLines(orderId)直接返回List
3.3 仓库加载聚合时,要把整个聚合拼齐
仓库的findById不是只查主表就完事了,它必须把聚合内的所有对象一起加载出来,组装成一个完整可用的聚合根。比如一个Order聚合包含订单头、订单行、优惠券明细,那findById至少要完成这三次查询、组装。
很多人在这一步偷懒:只查了订单头,订单行是懒加载的。听起来没什么,但懒加载有个致命问题——它把持久化的机制泄漏到了业务代码里。聚合根在执行校验规则时,需要遍历所有订单行,如果此刻订单行还是懒加载代理,可能触发N+1查询、可能报LazyInitializationException,也可能在理应是纯内存的业务逻辑里悄悄开了数据库连接。聚合根应当是纯内存对象,不感知数据库。
所以我在工程实践里一直推崇一个原则:持久化框架的懒加载,在仓库加载聚合时手动关闭,改成“即取即查”,一次性把聚合组装完整再返回。 性能确实会稍有损失,但换来的业务代码纯净度和可预测性,远远值这个价。这是DDD落地中性价比极高的一个取舍。
4. 工厂:把复杂创建过程收进来,别让构造器变成规则通道
聚合根把“修改”的门关死了,仓库把“存取”的门关好了。还剩下一个口子没堵:创建。
你以为创建不就是new Order(...)一下吗?但那是在业务简单的前提下。真实的订单创建,往往要经历很多步骤:
- 从购物车生成订单行,算小计
- 校验库存可用性
- 计算优惠券抵扣
- 计算运费规则
- 生成订单编号
- 设置初始状态
- 记录创建时间,发布“订单已创建”事件
如果这些逻辑全塞在构造函数里,构造函数会变得极长、难以测试;如果把逻辑塞在应用服务里,又回到了规则散落的老路。
4.1 构造器只负责“创建合法对象”,工厂负责“编排创建过程”
领域模型里,构造函数有一个职责边界:它保证对象创建完成后,处于一个合法的、可直接使用的基本状态。也就是说,构造函数里只做“缺了什么会出大事”的校验,比如订单ID不能为空、创建时间不能为空、初始状态必须是PENDING。
但复杂的业务编排不适合放在构造函数里,因为构造函数没法表达“业务上的上下文”。举个例子,一个订单的初始总价来自购物车里的商品,而购物车在另一个聚合里。你没法让Order的构造函数自己去查购物车,那会让实体内依赖其他聚合的仓库,直接破坏领域模型的分层。
这时就需要工厂。工厂在DDD里不是设计模式那个简单的“简单工厂”,而是一个专门承担领域对象创建过程的服务。它的职责是:把外部依赖和数据准备到齐,然后以“聚合根”的身份,调用聚合内部的方法完成创建,最终返回一个状态完整、规则已校验的聚合根。
4.2 一个订单工厂的示例
java复制@Component
public class OrderFactory {
// 工厂可以依赖其他聚合的仓库,因为创建过程本来就要去读别的聚合
private final CartRepository cartRepository;
private final ProductRepository productRepository;
private final DiscountService discountService;
public Order createFromCart(CartId cartId, CustomerId customerId) {
Cart cart = cartRepository.findById(cartId);
if (cart.isEmpty()) {
throw new BusinessException("购物车是空的,无法下单");
}
// 先new一个空的订单聚合根
Order order = Order.createPendingOrder(
OrderId.generate(),
customerId,
Clock.systemUTC()
);
// 通过聚合根方法而不是直接操作内部集合,把购物车项转成订单行
for (CartItem item : cart.items()) {
Product product = productRepository.findById(item.getProductId());
order.addLine(product, item.getQuantity());
}
// 计算优惠券
Discount discount = discountService.calculateBestFor(order);
if (discount != null) {
order.applyDiscount(discount);
}
return order;
}
}
关键点在于:工厂自己不做业务校验,它把商品逐条塞进order.addLine(...),由Order聚合根自己判断“数量合不合法”“总价超不超限”“能不能叠券”。工厂只负责从不同地方把所有原料找齐,然后调用聚合根的方法。这就是创建过程和业务规则的清晰分工。
如果你觉得工厂没必要,也可以把这段逻辑放在应用服务里,但那样应用服务就要依赖于CartRepository、ProductRepository、DiscountService,变得又胖又难测。有了工厂,应用服务只需要依赖工厂和仓库两个东西,逻辑清爽很多。
4.3 工厂与构造器之间有一条明显的分界线
记住这个分界线:创建对象只需要内部字段的,用构造器;创建对象还需要外部依赖、跨聚合查询、业务编排的,用工厂。 比如创建一条Address(地址)这种简单值对象,直接构造器就够了;创建一条Order这种需要从购物车迁移数据、计算折扣的复杂聚合,一定要走工厂。
很多团队把工厂理解为“用来给对象赋初始值的地方”,其实工厂真正要做的是“让对象从无到有的过程符合业务约束”。初始值只是最表面的一层。
5. 四个角色协同作战:一次完整下单操作里的分层防御链路
前面把四个概念单独讲完了。但真正的挑战在于它们怎么配合。我下面用一个完整的下单场景来串一遍,你感受一下每一层分别在拦什么。
先看整体调用链:
code复制应用服务(Application Service)
↓ 接收请求,不允许包含业务规则
领域工厂(Domain Factory)
↓ 组装聚合根,调用聚合根方法
聚合根(Order)
↓ 内部校验+修改状态
仓库(OrderRepository)
↓ 持久化完整聚合
↓ 发布领域事件
↓ 事务提交
5.1 第一层防线:应用服务只做“翻译”和“协调”
应用服务(Application Service)的定位是:接收来自接口层的DTO,翻译成领域模型的语言,然后调用领域层的方法。
java复制@Service
@Transactional
public class OrderApplicationService {
private final OrderFactory orderFactory;
private final OrderRepository orderRepository;
public PlaceOrderResult placeOrder(PlaceOrderCommand cmd) {
// 1. 工厂创建聚合根
Order order = orderFactory.createFromCart(
CartId.of(cmd.getCartId()),
CustomerId.of(cmd.getCustomerId())
);
// 2. 聚合根可以直接执行操作
order.place();
// 3. 仓库保存聚合根
orderRepository.save(order);
return PlaceOrderResult.of(order.getId());
}
}
这里有个非常重要的细节:@Transactional只出现在应用服务这一层。领域层、工厂层不感知事务。为什么?因为事务是一个跨聚合一致性的问题,它属于应用协调的范畴,不属于领域模型的职责。如果让聚合根自己开事务,它就会依赖数据库,领域模型又被拖回了基础设施的泥潭。
另外,应用服务不应该有if判断业务规则。你翻到有人写if (order.getStatus() == Status.PAID) { throw ... },那就说明规则放错地方了,应该把这段逻辑收进聚合根里,比如给聚合根加一个order.ensureNotPaid()这样的方法。
5.2 第二层防线:工厂把“原料准备”和“组装”完成
在5.1的代码里,orderFactory.createFromCart(...)负责从购物车仓库、商品仓库、优惠计算服务里取数据,不断调用order.addLine(...)和order.applyDiscount(...),把Order填充完整。这个过程如果失败了,比如商品库存不足,工厂会抛出异常;如果成功,返回的Order一定是一个已经通过全部不变量校验的合法订单。
注意,这里事务还没有开启——或者说,工厂的执行还是在应用服务的事务范围内,但工厂本身不感知。工厂的失败会导致整个请求失败,这是正确的:创建订单是一系列强一致操作,任何一个环节失败都不应该留下半成品。
5.3 第三层防线:聚合根把规则钉死
在Order.addLine里,你已经看到了:行数上限、状态校验、总价校验。在Order.applyDiscount里,有叠券校验。在Order.place里,还有状态机校验——只有PENDING状态的订单才能变成PLACED。
聚合根的这一系列方法,是整条链路的护城河。不管是从App调、从后台管理调、还是从定时任务调,只要入口最终落到了聚合根方法上,规则就必然生效。
5.4 第四层防线:仓库保证“持久化的是完整聚合”,而不是“某个字段”
最后,orderRepository.save(order)做的事情是:把整个Order聚合的状态统一写进数据库。它不用区分哪些字段变了、哪些没变,因为聚合根内部已经保证了这个对象从头到脚都是合法的。仓库的任务只是“保存这个完整的聚合”,仅此而已。
保存完成后,如果聚合内有领域事件(比如OrderPlaced),也是在仓库里统一收集、发布。为什么要放在仓库里?因为一个聚合从加载到保存的整个生命周期里,可能会积累多个事件,而这些事件必须在事务提交成功之后才发布,否则会出现“事件发了,数据没存上”的不一致。放在仓库的save里统一发布,正好卡在数据的持久化点上,时机最稳妥。
5.5 一条最重要的分层心法
把整条链路看成一个漏斗:接口层接收参数,应用服务翻译指令,工厂准备上下文和依赖,聚合根执行规则,仓库落库。每一层都不允许“往下多走一步”。应用服务不许直接改实体的字段,工厂不许自己写业务规则,聚合根不许依赖数据库,仓库不许提供绕过聚合根的方法。
只要每一层守住自己的职责,领域模型就是安全的。真正导致的模型被击穿的事故,百分之百是某一层越权了:要么应用服务直接改字段,要么仓库暴露了实体级方法,要么不经过工厂直接new聚合根。
6. 实战中最常见的失守姿势:盘点模型被穿透的四种典型现场
前面讲的都是理想链路。但现实项目中,总会有各种姿势把这条防线打出洞来。总结一下我遇到最多的情况,以及对应的纠偏办法。
6.1 失守姿势一:公共getter/setter把实体变成了“数据袋子”
这是最常见的一种。代码生成器一键生成实体类,每个字段都有getter和setter,聚合根里到处是this.status = status。业务规则变成应用服务里的一堆if,实体本身变成了一个没有任何防御能力的数据袋子。
纠偏办法:先封掉所有会破坏业务规则的setter。能私有化就私有化,不能私有化的改成包级私有,然后给聚合根增加带有业务语义的方法,比如void pay(), void cancel(String reason), void confirm()。这不是让你删掉getter——查询那个只读侧还是可以用,但写操作必须方法化。你一边写代码一边感受一下:如果实体上的方法名都是在描述“发生了什么”,那你的模型就活了;如果方法名全是setXxx,你的模型还是死的。
6.2 失守姿势二:聚合根从仓库取回来之后,被直接强转成可修改对象
有些团队用JPA或Hibernate,实体类天然带setter。从仓库取出Order之后,应用服务直接order.setStatus("COMPLETED"),完全绕过了聚合根的状态机。
纠偏办法:在实体类上,把业务上需要保护的字段的setter访问级别设置得很小(比如protected甚至private),只允许框架通过反射赋值。业务代码里能调到的setter,只有那些不影响核心逻辑的字段(比如备注、用户自定义标签)。一旦发现“这个setter调用后,业务约束可能被破坏”,立刻收权。
6.3 失守姿势三:为了“性能”,在仓库里塞了实体级查询
前面说过的findLinesByOrderId是典型。有些团队不以为意,说“我就查一下不修改”。但这种查询方法的存在,就像有人拿了一把备用钥匙,虽然平时不用,但工程上迟早会有人用。
纠偏办法:写侧查聚合必须走OrderRepository.findById,返回完整聚合。如果只是读数据做展示,走单独的QueryService,不要碰写模型的仓库。这个方案就是CQRS在实践中的落地——命令侧用领域模型、仓库;查询侧用轻量级查询接口,甚至直接写SQL都行。两边各走各的,互不干扰。
6.4 失守姿势四:把“跨聚合的一致性问题”硬塞进一个聚合里
有一种拧巴场景:业务上,“下单”必须同时扣减“库存”,有人就把“订单”和“库存”塞进同一个聚合,用一个事务锁住订单表和库存表。短期看似解决了问题,长期看聚合越来越臃肿,下单并发一高就死锁。
纠偏办法:重新审视这个一致性是不是“必须强一致”。大多数业务场景,“库存扣减”是可以放在扣减请求队列里异步处理的,甚至允许超卖一点再补偿。如果真的必须强一致,那也不是通过把两个聚合合并来解决,而是在应用服务层用分布式事务或本地消息表来保证。把跨聚合事务放进领域模型,是拿错了工具。
我过去在项目里用过一张表来记录这类问题的自查情况,特别管用:
| 场景 | 错误做法 | 正确做法 |
|---|---|---|
| 修改订单行数量 | 应用服务直接取lines,for循环改数量,再保存 | Order.changeLineQuantity(lineId, newQty),规则由聚合根校验 |
| 查询订单行列表 | OrderRepository.findOrderLinesByOrderId | 写侧用Order.findById,查侧用QueryService |
| 创建复杂订单 | 应用服务里new Order并逐字段set | OrderFactory.createFromCart() |
| 订单+库存 | 把库存塞进订单聚合 | 领域事件,异步扣减,最终一致 |
| 新同事不熟悉DDD | 找一个“通用”的实体的Repository直接改数据 | 代码评审时,用“这条更新路径是否经过了聚合根”作为一票否决项 |
7. 怎么判断你的防护体系合格:一套来自实战的验收清单
我每次做完一个模块的DDD改造,都会拿着同一套清单自测。这套清单比任何理论都有用,你直接抄作业就行。
第一,遍历所有写操作,问一个问题:这次修改有没有经过聚合根的方法? 如果有一个写操作用的是mapper.updateXxx直接改字段,或者repository.save(order)之前还有order.setStatus(...)这样的代码,那就是不合格的。合格的标准是:所有业务状态的流转,都能在聚合根的代码里找到对应的方法名。
第二,删除聚合内所有实体的Repository,看看编译能不能过,业务代码受到的影响有多大。 如果某个业务功能因为删掉OrderLineRepository就完全没法实现了,说明你的业务逻辑错误地依赖了实体的直接访问。如果影响很小,只影响了一些统计查询,那就说明边界画得好。
第三,给聚合根写一组单元测试,模拟各种非法操作,看它能不能全部拦截。 比如订单已支付再改行、行数超限、叠券超过业务限制、总价超上限,这些测试应该全部抛异常。如果这些异常不是从聚合根抛出来的,而是从应用服务里使用了一堆if判断抛出来的,那还不是真正的DDD。
第四,做一次“代码考古”——在项目历史提交里找一找,有没有过“绕过聚合根直接改数据”的bug。 这个信号很真实。如果一个项目过去半年里出过类似“后台补单没走校验”“定时任务直接改状态导致状态流转错乱”这类事故,那就说明当前模型的防御还有洞。
我自己在实际项目里还有一个习惯:在代码评审的检查单里加一条——“如果这段改动不需要经过任何一个聚合根,它凭什么存在?”新同事看到这条一般会愣一下,然后思考一下再动手,这就足够了。DDD的整套防御思想,最终要落到每一个工程师的直觉里,而不只是架构图上那四个名词。
这四件套的协同关系,是DDD落地中最不容易学、又最不能省的部分。希望这篇能帮你把脑子里分散的概念串成一条完整的防线。
