本节摘要:聚合建好之后还剩两件"身后事":状态要存得进去,变化要说得出去。仓库(Repository)用"内存集合"的幻象把持久化细节挡在领域之外;领域事件(Domain Event)把"已发生的事"变成显式的、可订阅的事实,让聚合之间的协作从互相调用改为事实广播。本节给出两者的代码形态,并解释仓库接口为什么要定义在领域层——这个细节是第 4 章依赖倒置的伏笔。
初看这是伪问题:存数据嘛,数据访问层的事。但把 3.2 的工厂产物拿在手里看看——一个携带业务方法、守着不变量的 Order 对象——马上遇到尴尬:传统数据访问层的对象是"表映射",字段加 getter setter,业务方法无处安放。领域对象要活着,存储就必须迁就它,而不是反过来。仓库模式就是这个"迁就"的正式化:对领域层而言,仓库就是一个内存集合——按 id 取、按条件查、放进去、删掉;至于背后是单表、分库还是远程服务,集合不关心。
这个幻象靠两条纪律维持。第一,一个聚合根一个仓库,且只有聚合根配得上仓库——订单有仓库,订单行没有;行是跟着聚合整体存取的,单独能查"某一行"就意味着有人想绕过聚合改它,这是 3.5 要拆的边界漏洞。第二,仓库返回的是聚合,不是数据结构——返回自定义 DTO 的"仓库"其实是查询服务换了件衣服,第 6 章会把它送回查询侧的正规编制。
先看领域层怎么声明它:
// 领域层:仓库接口——只出现聚合与业务词,不见任何存储概念 public interface OrderRepository { Order findById(TradeOrderId id); // 按 id 取,取不到抛异常 Optional<TradeOrder> findPayable(TradeOrderId id); // 按业务语义命名查询 void save(TradeOrder order); // 保存聚合整体 boolean exists(TradeOrderId id); }
接口里有三处值得停顿。签名里只有 TradeOrder 和 TradeOrderId,没有表名、没有 SQL 参数——领域层读这个接口不需要懂存储。查询方法用业务语义命名(findPayable,找"可支付的订单"),而不是 selectByStatusAndAmount——后者是把存储语句搬进了领域语言。save 收的是聚合整体:调用方不指明"改了哪个字段",存储层自己对比快照——聚合是一致性单元,保存也以聚合为单位。
再看基础设施层怎么兑现:
// 基础设施层:仓库实现——依赖领域接口,而不是相反 public class OrderRepositoryImpl implements OrderRepository { private final OrderDao dao; // 具体的数据访问机制 private final DomainEventPublisher events; @Override public Order findById(TradeOrderId id) { OrderSnapshot snap = dao.load(id.value()); return OrderFactory.rebuild(snap); // 用 3.2 的"重建"工厂,不触发业务事件 } @Override public void save(TradeOrder order) { dao.store(order.snapshot()); // 存快照 events.publishAll(order.pullEvents()); // 顺路把聚合攒下的事件发出去 } }
注意实现里 import 的是领域接口与领域类型——依赖箭头从基础设施指向领域。这一根箭头的方向,决定第 4 章"整洁架构"能不能成立:接口在领域层,领域代码就不必 import 任何存储框架,单测时给仓库配一个内存实现即可,领域逻辑的测试不需要数据库。3.2 说的"分摊服务测试不起库"在仓库这层同样成立。

事件的名字有一个铁规矩:过去时态。OrderPaid(订单已支付)、ShipmentSigned(货已签收)、CouponRedeemed(券已核销)。过去时不是语法洁癖,它划清了两种东西的界线:事件是事实,发生后不可辩驳、不可撤回,只论响应,不问许可;而"请发货"这类将来时态的消息是命令,是请求,可以被拒。混用两者的下场是灾难性的:把 CancelOrder(取消订单的请求)当事件广播,三个订阅方各自执行取消,一个成功两个失败,"已取消"就成了薛定谔的状态。
// 事件本体:不可变的事实快照 public record OrderPaid( String eventId, TradeOrderId orderId, Money payable, Money discount, Instant paidAt ) { public static OrderPaid of(TradeOrder order, Instant at) { return new OrderPaid(UUID.randomUUID().toString(), order.id(), order.payable(), order.discountTotal(), at); } } // 聚合内部:状态迁移与事实产出在同一方法里完成 public class TradeOrder { private final List<DomainEvent> events = new ArrayList<>(); // 聚合攒事件 public void markPaid(Instant at) { if (status != TradeStatus.WAIT_PAYMENT) { throw new IllegalStateException("仅待支付订单可支付"); } this.status = TradeStatus.PAID; this.paidAt = at; events.add(OrderPaid.of(this, at)); // 变更与事实同源:改了什么,就发生了什么 } public List<DomainEvent> pullEvents() { // 仓库保存时取走 List<DomainEvent> out = List.copyOf(events); events.clear(); return out; } }
markPaid 这一个方法浓缩了领域事件的精髓:事实从变更现场直接产出,不是事后由别人补记。这保证了事实与状态永不脱节——只要走业务方法改状态,事件就一定在。财务要记账、积分要累计、通知要回执,都只是订阅 OrderPaid 的下游,交易侧不知道也不需要知道它们存在。回看 1.4 第三轮实验里"积分系统订阅订单已支付事件"的那句话,现在它有了完整的实现形态。
⚠️ 一个被低估的工程细节:事件的发布要和状态保存放在同一个数据库事务里(如图所示,仓库顺路发布)。事件先于状态落库,订阅方会读到"未发生的支付";状态先落库、事务失败,订阅方会收到"幽灵支付"。两者绑定在一个事务里,才谈得上可靠。
模型的砖、逻辑的住址、持久化与事实都齐了,还差最后一层骨架:这些代码在目录里怎么摆,才能让六个月后新来的工程师一眼看懂地图?3.4 模块与包结构。