跳到正文

战术设计:实体、值对象、聚合、仓储、领域服务与领域事件 ​

一句话:战术设计把业务规则放进模型里,让每条规则只有一个归属;聚合是其中的一致性边界,跨出边界就只能靠领域事件。它换来的是规则可验证,代价是聚合划分和并发控制都要自己设计。

为什么需要 ​

贫血模型(Anemic Domain Model)的典型形态是:实体只有 getter/setter,所有判断都在 Service:

java
// 反例:规则散落在调用点,两个入口就可能写出两种支付逻辑
if (order.getStatus() == PENDING) {
    order.setStatus(PAID);
    order.setPaidAt(now);
}

问题不在「代码难看」,而在规则没有唯一归属:新增一个支付入口,就要记得再写一遍校验;字段可以被任何代码改,聚合声称的「不变量」没有执行点。战术设计给每类复杂度一个位置:

复杂度归属判断信号
身份与生命周期实体(Entity)两个对象属性相同,但仍要区分「是哪一个」
无身份的整体概念值对象(Value Object)内容相同即相等,适合做成不可变
必须同时成立的不变量聚合(Aggregate)一次修改要保证多条记录一起变
跨聚合的领域规则领域服务(Domain Service)规则不属于任何单个实体
聚合的存取仓储(Repository)需要按聚合整体读写,而不是按表
跨聚合通知领域事件(Domain Event)下游只关心「已经发生了什么」

核心机制 ​

值对象:用不可变换掉一类并发 bug ​

金额 = 数值 + 币种。两个「100 元」没有身份区别,内容相同就相等,所以它是值对象。做成不可变后,转账不是「改余额」,而是「用新对象替换旧对象」,不会出现半个对象被改的状态。

同一个概念在不同上下文里可能一个是实体、一个是值对象:地址在订单里是值对象(内容相同即相同),在物流上下文里可能因为要保留变更记录而成为实体。没有绝对的分类,只有上下文内的选择。

聚合:一致性边界,不是对象图 ​

聚合(Aggregate)是一组必须一起变化的对象,对外只有一个入口——聚合根(Aggregate Root)。三条规则:

  1. 外部只能通过聚合根修改内部对象。order.addLine(...) 可以,order.getLines().get(0).setPrice(...) 不可以。
  2. 聚合内强一致,聚合间最终一致。订单总价必须等于订单项之和,这是不变量;库存不必和订单同时变,用事件异步扣减。
  3. 聚合之间按 ID 引用。Order 持有 CustomerId,不持有 Customer 对象,避免一次加载半个数据库。

聚合大小按「必须一起变」划,不按「业务上相关」划。太大 → 锁竞争、单事务跨表多;太小 → 不变量被拆散,跨聚合事务泛滥。

并发:版本号是聚合的标配 ​

同一个聚合可能被两个请求同时修改。两种做法:

  • 乐观锁:读的时候带 version,写的时候 WHERE version = ?,影响行数为 0 就说明被别人改过,抛异常让上层重试。适合冲突不频繁的业务。
  • 悲观锁:SELECT ... FOR UPDATE 锁住聚合行。适合冲突频繁、重试代价高的场景,但会放大锁等待。

乐观锁的关键不是「加个字段」,而是重试策略和幂等:重试次数有上限,退避有抖动;跨边界的外部调用不能盲目重试。版本号冲突率高,通常说明聚合过大或热点过于集中。

实战场景 ​

一、聚合过大:一次备注修改撞掉别人的支付 ​

现象:Order 聚合里装着订单项、支付记录、优惠明细、物流节点。客服改一行备注、用户发起支付、履约更新状态三类操作互相冲突,乐观锁版本冲突率升到 8%(经验值,需校准),用户侧表现为“提交失败请重试”;单次加载要读 5 张表,详情接口 P99 400ms。

排查:聚合过大有三条可量化的证据。

定位层取证据判定
版本冲突率按操作类型统计 WHERE version = ? 影响行数为 0 的比例不同业务动作互相冲突 → 聚合内混入了不相关变更原因
事务内表数一条 save(order) 触达的表数量与 SQL 条数跨 5 表以上 → 单事务过重
加载规模聚合加载的对象数与耗时;有无懒加载缺失“一次加载半个数据库”的征兆
引用形态Order 是否持有 Customer、Payment 对象而非 ID对象图扩散,聚合边界不清晰
不变量位置“总价 = 订单项之和”这类规则涉及哪些表不变量只覆盖订单项 → 支付记录不该在同一聚合

聚合大小按“必须一起变”划,不按“业务上相关”划;冲突率高通常说明聚合过大或热点过于集中。

对症做法:

java
// 1. 按不变量重新划聚合:Order 只管“订单项与总价”,其余按 ID 引用
public class Order {
    private OrderId id; private CustomerId customerId;      // 跨聚合用 ID
    private List<OrderLine> lines; private Money total;     // 强一致部分留在这里
    public void addLine(ProductSnapshot snap, int qty) { ...; recomputeTotal(); }  // 唯一入口
}
// 支付、物流各自成聚合,有自己的根与版本
public class PaymentRecord { private OrderId orderId; private PaymentStatus status; long version; }

// 2. 聚合间用事件达成最终一致:支付成功不改 Order 内部对象
@EventListener public void on(PaymentSucceeded e) { order.markPaid(e.orderId(), e.amount()); }

// 3. 冲突处理要有上限与抖动,且跨边界的外部调用不盲目重试
int attempts = 0;
while (attempts < 3) {
    try { repo.save(order); break; }
    catch (OptimisticLockException ex) {
        sleepWithJitter(base * (1L << attempts));      // 指数退避 + jitter
        order = repo.load(order.id()); attempts++;
    }
}
if (attempts == 3) throw new ConcurrentModificationException(order.id());
text
拆分的判据不是“表变多”,而是不变量是否被拆散:
如果“总价 = 订单项之和”需要跨两个聚合校验,说明切错了,
要么收回同一聚合,要么把这条规则降级为“可延迟校验 + 对账”。

复盘要点:验收口径是版本冲突率与详情接口 P99 同时下降。把“一个聚合的写路径涉及的表数 ≤ 3”“聚合间不得持有对方对象引用”写成架构测试;每次出现冲突率告警时先看变更原因是否同属一个不变量,而不是先加大重试次数——重试掩盖的是划分问题。

二、不变量被拆到两个聚合:优惠券重复核销 ​

现象:规则“同一张优惠券只能被同一订单使用一次”由两个聚合共同承担:Order 记录用了哪张券,Coupon 记录核销状态。上线后发现同一张券在同一订单里被核销两次,对账差异涉及 60 余笔(经验值,需校准);两侧各自看都“合法”。

排查:跨聚合的强一致不变量一定会在并发下失效。

定位层取证据判定
不变量涉及的对象把规则写成一句话,列出其中名词属于几个聚合两个聚合 → 不能靠本地事务保证
校验与写入的间隙trace 里两次请求读取 Coupon 状态的时间点都在写入前读到“未使用”→ 典型竞态
数据库约束是否有唯一索引兜底只有应用层判断,无 DB 约束
一致性期望业务是否接受“短暂不一致 + 对账”决定是否必须收回同一聚合
幂等键同一 orderId + couponId 是否可重入无幂等键时重试即重复

聚合内强一致,聚合间只能最终一致;把“互斥”类规则跨聚合实现,等于把不变量交给了时序运气。

对症做法:

text
1. 首选把不变量收回一个聚合:核销的互斥属于“券”的行为,
   让 CouponUsage 聚合自己保证(券 + 使用记录同聚合),订单侧只引用 couponId。
2. 收不回时用数据库唯一约束强制互斥(这是最省事的正确做法):
   UNIQUE (coupon_id, order_id) 与 UNIQUE (coupon_id) WHERE status='USED'
   冲突即拒绝,不依赖“先查再写”。
sql
ALTER TABLE coupon_usage ADD CONSTRAINT uk_coupon_order UNIQUE (coupon_id, order_id);
-- 核销写入用“插入即判定”,不读后写
INSERT INTO coupon_usage (coupon_id, order_id, used_at) VALUES (?, ?, NOW())
  ON DUPLICATE KEY UPDATE used_at = used_at;    -- 影响行数 0 表示已存在
java
// 3. 幂等键贯穿:事件重放、用户重复提交都只生效一次
public void apply(String eventId) {
    if (processed.exists(eventId)) return;          // 与业务写同事务标记
    txTemplate.execute(s -> { coupon.markUsed(eventId); processed.mark(eventId); return null; });
}
text
4. 如果业务接受最终一致:改成本地消息表 + 对账,
   并在规则文档里把“可能短暂看到未核销”写清楚,见 05 篇的一致性选择。

复盘要点:判据是“不变量的实现位置”:每条业务规则要能回答“它由哪个聚合保证”。互斥类规则必须有数据库唯一约束兜底,应用层判断只作为快速失败。评审时把“这条不变量涉及几个聚合”作为固定问句,涉及两个就要求给出方案,而不是留下一句“靠事件保证”。

三、getLines().get(0).setPrice():总价与明细不一致 ​

现象:对账任务每天报告少量订单“总价 ≠ 明细之和”,日均 3~8 笔(经验值,需校准)。修数脚本改回后再次出现。代码评审发现改价逻辑散落在应用服务里:直接拿到明细对象改价,没有触发总价重算。

排查:不变量被绕开的位置,从代码形态就能找出来。

定位层取证据判定
集合是否可变getLines() 是否返回内部 List返回可变引用 → 外部可改内部状态
内部对象是否有公开 setterOrderLine.setPrice() 存在有 setter 就等于有绕过路径
写入口数量统计修改明细的调用点所在层出现在应用服务/工具类 → 未走聚合根
不变量断言加载与保存时是否校验总价一致无断言 → 漂移只能靠对账发现
历史数据差异是否集中在某类操作(改价、退款)定位是哪条绕过路径

“外部只能通过聚合根修改内部对象”是聚合的第一条规则,违反它的代价是不变量悄悄失效。

对症做法:

java
public class Order {
    private final List<OrderLine> lines = new ArrayList<>();
    private Money total = Money.zero(currency);

    // 唯一入口:业务动词命名,内部维护不变量
    public void changeLinePrice(int lineNo, Money newPrice) {
        OrderLine line = lines.get(lineNo);
        line.changePrice(newPrice);           // 包私有/无公开 setter,改价只走这里
        recomputeTotal();
    }
    private void recomputeTotal() {
        this.total = lines.stream().map(OrderLine::amount).reduce(Money.zero(currency), Money::add);
    }
    public List<OrderLine> lines() { return List.copyOf(lines); }   // 返回不可变副本
    public void assertInvariant() {
        if (!total.equals(sumOfLines())) throw new IllegalStateException("order invariant broken: " + id);
    }
}
java
// 仓储保存前调用断言;测试里用同一断言固定规则
@Test void totalAlwaysEqualsLineSum() {
    Order o = orderFixture.withTwoLines();
    o.changeLinePrice(0, money("30.00"));
    o.addLine(snapshot("sku-9"), 2);
    o.assertInvariant();
    assertThat(o.total()).isEqualTo(money("70.00"));
}
text
配套约束:
1. OrderLine 的 setter 改为包私有,构造后字段私有且不提供写方法;
2. 用一条静态检查禁止 app/、api/ 层调用领域对象的 set* 方法;
3. 历史脏数据用一次性修复 + 上线后的“加载即校验”日志统计绕过点。

复盘要点:值对象与聚合根换来的“规则只有一个归属”需要机械保护:不可变副本、包私有写方法、加载/保存时的不变量断言,三者缺一,绕过就会重新出现。对账差异归零只是结果指标;过程指标是“领域对象 setter 数量”与“app 层调用领域 setter 的检出数”,都应为 0。同一个概念在不同上下文里可以是实体或值对象,这种选择也要在画布里写明,见 建模方法。

最小上手 ​

下面是一个可运行的最小聚合。省略 import、辅助类型(OrderId、SkuId、OrderStatus、DomainEvent、OrderPaid)、日志和异常基类;完整测试用 JUnit 5。

java
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.scale() > 2) throw new IllegalArgumentException("scale <= 2");
    }
    public Money add(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(amount.add(other.amount), currency);
    }
    public Money multiply(int qty) { return new Money(amount.multiply(BigDecimal.valueOf(qty)), currency); }
}
public record OrderLine(SkuId skuId, Money unitPrice, int quantity) {
    public OrderLine { if (quantity <= 0) throw new IllegalArgumentException("quantity > 0"); }
    public Money subtotal() { return unitPrice.multiply(quantity); }
}
public final class Order {
    private static final Currency CNY = Currency.getInstance("CNY");
    private final OrderId id;
    private final List<OrderLine> lines = new ArrayList<>();
    private OrderStatus status = OrderStatus.PENDING;
    private long version;
    private final List<DomainEvent> events = new ArrayList<>();

    public Order(OrderId id) { this.id = id; }
    public void addLine(OrderLine line) {
        if (status != OrderStatus.PENDING) throw new IllegalStateException("only pending order can add line");
        lines.add(line);
    }
    public void pay(Instant now) {
        if (status != OrderStatus.PENDING) throw new IllegalStateException("order already " + status);
        if (lines.isEmpty()) throw new IllegalStateException("empty order cannot be paid");
        this.status = OrderStatus.PAID;
        this.events.add(new OrderPaid(id, total(), now));
    }
    public Money total() {                       // 每次计算,天然守住“总价 = 明细之和”
        return lines.stream().map(OrderLine::subtotal).reduce(new Money(BigDecimal.ZERO, CNY), Money::add);
    }
    public List<DomainEvent> pullEvents() { var copy = List.copyOf(events); events.clear(); return copy; }
    public long version() { return version; }
    // id()、status()、lines() 省略
}

仓储端口和 JDBC 适配器(乐观锁落在 UPDATE 的 WHERE 上):

java
public interface OrderRepository {
    Optional<Order> findById(OrderId id);
    void save(Order order);          // 新聚合 insert,已存在则按 version 更新
}

public void save(Order o) {
    int affected = jdbc.update(
        "UPDATE orders SET status = ?, version = version + 1 WHERE id = ? AND version = ?",
        o.status().name(), o.id().value(), o.version());
    if (affected == 0) throw new OptimisticLockingFailureException(o.id());
    // order_lines 在同一本地事务内按 order_id 重写;成功后需回写新 version,省略
}

应用服务负责事务和重试,领域层不出现 @Transactional:

java
@Transactional
public void pay(PayOrderCommand cmd) {
    Order order = orders.findById(cmd.orderId()).orElseThrow();
    order.pay(clock.instant());
    orders.save(order);
    outbox.append(order.pullEvents());   // 与状态同一事务,详见 05 篇
}

测试至少要覆盖三条不变量:

java
@Test void paying_twice_is_rejected() { /* 第一次成功,第二次抛 IllegalStateException */ }
@Test void empty_order_cannot_be_paid() { /* 抛 IllegalStateException */ }
@Test void total_always_equals_lines() { /* 加两行后断言 total == 两行 subtotal 之和 */ }

运行:mvn -q test -Dtest=OrderAggregateTest。如果测试里还要连数据库,说明聚合没有和持久化分开。

生产动作 ​

  • 聚合指标:optimistic_lock_failures_total、单事务持有时长 P99、单事务写入行数。冲突率持续升高时,先看是不是同一个聚合根被高频并发修改,而不是先调大锁等待时间。
  • 事务边界:一个应用服务方法对应一个本地事务;跨聚合协作走事件或 Saga。把「一个聚合一个事务」当默认值,不是绝对律令——长流程需要补偿,不能靠一个大事务硬扛。
  • 排障:接口偶发 500,日志是乐观锁异常 → 查该聚合的版本冲突率和重试次数 → 冲突集中在同一 ID,说明热点聚合;拆聚合或把非强一致逻辑改为事件异步。SHOW ENGINE INNODB STATUS\G 里 LATEST DETECTED DEADLOCK 可确认是否存在行锁互等。
  • 排障:事务超时或事件重复消费 → 用 performance_schema.events_statements_summary_by_digest 找慢 SQL;一次事务写五张以上表就检查聚合是否过大。重复消费查消费端幂等键和唯一索引,没有业务键去重就补。

边界 ​

  • 聚合不是越大越「安全」:为了强一致把所有关联对象塞进一个聚合,会把并发吞吐锁死。
  • 不是所有表都需要仓储:报表、列表、检索走读模型(CQRS),仓储只服务聚合生命周期。
  • 领域服务不是「万能的 Service」:它只装跨聚合、且不属于任何实体的规则;编排、权限、事务仍在应用层。
  • 事件不是免费的:引入 Outbox、幂等、重放之后,调试链路会变长。没有跨聚合解耦需求时,直接调用更简单。

讲清楚 ​

战术设计解决的是「规则放在哪」。值对象用不可变换掉一类并发问题;实体负责身份和生命周期;聚合把必须一起变的东西圈成一个一致性边界,外部只能通过聚合根改它,聚合之间按 ID 引用、用事件协作。并发上,聚合标配版本号,写入时用 WHERE version = ? 做乐观锁,冲突就重试,冲突率高说明聚合划分有问题。仓储是聚合的存取端口,接口在领域层、实现在基础设施层,报表查询不要塞进仓储。判断标准只有一条:随便抽一条业务规则,你能指出它在哪个类、哪个方法里被执行。

初稿 2026-10 | 最后修订 2026-10

把一门技术讲透到既懂也会用