跳到内容

第 6 卷 / 第 20 章 / 第 3 课

20.3 上下文集成:领域事件、ACL 与模型演进 ​

限界上下文之间必须交换信息,但不应共享同一套内部模型。集成边界负责翻译语言、隔离变化,并把本地事务产生的事实可靠地传播出去。

领域事件与集成事件分开 ​

领域事件表达上下文内部已经发生的业务事实。下面接续上一课,DomainEvent 是项目的标记接口;本例仍采用最多 1024 人的容量规则:

java
import java.util.Objects;

public record RegistrationConfirmed(
    TournamentId tournamentId,
    PlayerId playerId,
    int confirmedCount
) implements DomainEvent {
    public RegistrationConfirmed {
        Objects.requireNonNull(tournamentId);
        Objects.requireNonNull(playerId);
        if (confirmedCount < 1 || confirmedCount > 1024) {
            throw new IllegalArgumentException("invalid confirmed count");
        }
    }
}

它可以驱动同一上下文内的规则,但不一定适合作为公开消息。集成事件是面向外部消费者的稳定契约,可能隐藏内部字段、改变命名并增加版本元数据:

json
{
  "eventId": "a7726f68-9b1b-46df-84c2-bc0f786d6e8c",
  "eventType": "TournamentEntryConfirmed",
  "schemaVersion": 1,
  "aggregateVersion": 12,
  "entryId": "entry-92",
  "tournamentId": "tournament-7",
  "participantId": "participant-42",
  "confirmedAt": "2026-08-03T10:15:30Z"
}

这是自定义集成事件信封。内部领域事件没有 entryId、确认时间或持久化版本,应用服务必须从本次报名记录和事务结果取得它们,并完成内部 ID 到公开 ID 的映射,不能让 relay 临时编造。eventId 与 confirmedAt 在重发时保持不变。

把内部类直接序列化上 broker,会让重构字段变成跨团队破坏性变更。

事件何时发布 ​

聚合可以在执行业务行为时记录领域事件,但外部发布应在事务成功之后,并通过 Outbox 保留可靠发布意图:

text
聚合产生领域事件
  → 应用服务保存聚合
  → 同一事务写 Outbox 集成事件
  → 提交后 relay 发布

如果事务回滚,不能对外发布“报名已确认”。如果 relay 重复发布,消费者必须幂等。

Anti-Corruption Layer 保护本地语言 ​

这里 ACL 指 Anti-Corruption Layer,不是访问控制列表。假设某遗留系统约定状态 7 为授权成功、9 为拒绝,且使用 ORD、CUS_NO 字段。它们都是教学约定。以下适配器依赖项目的付款类型、客户端和映射函数;映射函数负责单位、身份和稳定 operationId,客户端用 LegacyTransportException 表示结果可能未知的通信失败:

java
import java.util.Objects;
import java.util.function.Function;

final class LegacyBillingAdapter implements PaymentAuthorization {
    private final LegacyBillingClient client;
    private final Function<PaymentRequest, LegacyOrder> toLegacyOrder;

    LegacyBillingAdapter(LegacyBillingClient client,
                         Function<PaymentRequest, LegacyOrder> toLegacyOrder) {
        this.client = Objects.requireNonNull(client);
        this.toLegacyOrder = Objects.requireNonNull(toLegacyOrder);
    }

    public Authorization authorize(PaymentRequest request) {
        Objects.requireNonNull(request);
        LegacyOrder order = Objects.requireNonNull(toLegacyOrder.apply(request));
        LegacyOrderResponse response;
        try {
            response = client.createOrder(order);
        } catch (LegacyTransportException e) {
            return Authorization.unknown(request.operationId());
        }
        if (response == null) {
            return Authorization.unknown(request.operationId());
        }
        return switch (response.statusCode()) {
            case 7 -> Authorization.approved(response.orderNo());
            case 9 -> Authorization.declined(response.reasonCode());
            default -> Authorization.unknown(request.operationId());
        };
    }
}

Authorization.unknown(operationId) 是显式未知结果,调用者应按操作 ID 查询,不能按“可用性失败”无限重试。已知状态的订单号和拒绝原因必须在客户端解码时校验;原始状态与传输错误应记入脱敏诊断。代码展示转换分支,不包含真实网关协议或重试实现。

ACL 的职责超过 DTO Mapper:它还翻译错误、身份、时间、单位、幂等和一致性语义。若上游无法区分“拒绝”和“暂时不可用”,ACL 也不能凭空制造可靠语义,只能显式暴露不确定性。

Shared Kernel 要极小且共同治理 ​

两个上下文共享少量模型或代码时,任何变化都需要共同协调。Shared Kernel 只适合真正稳定、双方共同拥有的部分,例如双方明确共管的小段模型及其标识规则。只共享生成的消息 DTO 或 schema,并不自动等于 Shared Kernel;它也可能只是 Published Language 的代码制品。

把全部 common-domain 放进共享包会重新形成分布式单体。复制一个简单值类型,有时比永久共享演进成本更低。

模型会随认知演进 ​

领域模型不是一次工作坊定稿。生产反馈可能暴露:

  • 同一个词实际包含两个生命周期;
  • 一个聚合因并发冲突过多需要拆分;
  • 原以为最终一致的规则其实要求预留;
  • 上下文之间的调用方向与团队责任不匹配;
  • 支持人员需要模型中不存在的中间状态。

演进步骤可以是:

  1. 用实例和事故描述当前模型的失败;
  2. 与领域专家更新语言和不变量;
  3. 调整代码边界与契约;
  4. 用 expand–migrate–contract 迁移数据;
  5. 更新事件、监控、手册和团队责任;
  6. 确认旧消费者和历史重放都不再依赖后,删除旧语言和兼容层。

什么时候不需要完整 DDD ​

数据录入、简单 CRUD、短寿命内部工具或成熟通用领域,可能只需要清晰分层和模块边界。DDD 的建模投入适合业务规则复杂、语言歧义高、变化长期且错误代价大的核心领域。

不要用战术模式把简单问题复杂化,也不要因简单外围功能不需要 DDD,就否定核心领域进行深度建模的价值。

第 6 卷专业链路回顾 ​

text
构造与契约
→ 需求、架构、测试、重构和交付
→ 设计原则与对象模式
→ 分层、端口和依赖规则
→ 模块化单体与服务提取
→ 韧性、一致性、部署和事件
→ AI 系统、API 契约与领域边界

这条链路的共同目标是:把变化、失败和责任限制在可理解的边界内,并用测试、契约和运行反馈证明边界有效。

参考资料 ​

Built with VitePress | Software Systems Atlas