主题
战略设计:领域、子域、限界上下文与上下文映射
一句话:战略设计把业务空间切成可以独立演进的边界,产出是上下文画布和上下文映射表,不是代码。它换来了模型与团队的自治,代价是每跨一次边界都要付契约和翻译的成本。
为什么需要
不做战略设计,系统会出现三种可观察的病症:
- 同名不同义:开户、交易、营销三个模块各有一个
Customer,字段不同。营销改手机号,开户的证件校验跟着报错。 - 按技术层拆服务:一个下单请求穿过 controller 服务、service 服务、dao 服务,一次业务五个 RPC,分布式事务越补越多。
- 投入平均主义:核心域和日志、消息、权限这些通用能力拿一样的研发预算,核心域反而没人建模。
根因不是技术选型,而是没有回答「哪块业务值得深挖、模型在哪里成立、边界之间谁依赖谁」。战略设计就是回答这三件事。
核心机制
问题空间:领域与子域
领域(Domain)是你所在的业务范围;子域(Subdomain)是其中按业务能力切出的一块问题。子域分三类,判断依据不是技术难度,而是是否构成差异化、是否买得到:
| 类型 | 判断问题 | 投入策略 | 例子 |
|---|---|---|---|
| 核心域(Core Domain) | 去掉它,公司还靠什么竞争? | 最强的人、最深的建模、自研 | 银行的信贷定价;交易平台的风控策略 |
| 支撑域(Supporting) | 核心域需要它,但同行都一样吗? | 自研但不追求领先,控制成本 | 权限、审批流、消息通知 |
| 通用域(Generic) | 市场上有没有成熟产品或开源方案? | 能买就买、能用开源就用 | 支付通道、日志、消息中间件、对象存储 |
子域是问题空间的划分,不等于团队结构,也不等于代码包。一个子域可能由多个团队共建;一个团队也可能负责多个子域。
解决方案空间:限界上下文
限界上下文(Bounded Context,BC)是一个模型能够保持一致的最小边界:边界内,术语只有一个含义,模型只有一个版本,通常由一个团队拥有。Eric Evans 的原话是「模型只在它自己的边界内有效」。
同一个词在不同上下文可以有完全不同的模型:
| 上下文 | 「客户」的含义 | 关键属性 |
|---|---|---|
| 开户 | 法律主体 | 证件号、姓名、证件类型 |
| 交易 | 账户持有者 | 账号、余额、限额 |
| 营销 | 可触达对象 | 手机号、偏好、渠道 |
边界内一致,边界外翻译。把三个「客户」合成一个类,等于让三个变更原因互相踩踏。
子域与限界上下文的对应关系(最容易被讲混的地方):
| 关系 | 何时出现 | 注意 |
|---|---|---|
| 一个子域 → 一个 BC | 最常见:核心子域独立建模、独立团队 | 一对一不是强制,只是默认起点 |
| 一个子域 → 多个 BC | 子域内语言或生命周期明显分裂;团队需要独立发版 | 多出来的 BC 之间仍要画映射 |
| 多个子域 → 一个 BC | 小系统起步期;支撑子域并入核心 | 边界内要保留模块边界,否则日后拆不动 |
| 通用子域 → 外部 BC | 采购 SaaS、开源中间件 | 用防腐层隔离供应商模型 |
一句话:子域回答「业务上哪块重要」,BC 回答「模型在哪里成立」。前者是问题,后者是解法,不能互相替代。
上下文映射:八种关系
映射描述两个 BC 之间的组织与依赖关系。箭头从下游指向上游(下游依赖上游):
| 关系 | 方向 | 含义 | 适用 | 代价 |
|---|---|---|---|---|
| 合作(Partnership) | 双向 | 两边一起演进,失败一起承担 | 下单与支付这种强耦合流程 | 需要同步排期,任一方的变更都会拖住对方 |
| 共享内核(Shared Kernel) | 双向 | 共享一小块模型和代码 | 金额、ID 等极小公共概念 | 共享部分改动需双方评审,边界容易越共越大 |
| 客户-供应商(Customer-Supplier) | 下游依赖上游 | 下游提需求,上游按契约交付 | 有明确上下游、能排期 | 上游优先级不一定听下游 |
| 顺从者(Conformist) | 下游依赖上游 | 下游直接采用上游模型,不做翻译 | 上游不可协商,且模型可接受 | 下游失去建模自由 |
| 防腐层(Anti-Corruption Layer,ACL) | 下游依赖上游 | 下游建翻译层,隔离上游模型 | 对接遗留系统、第三方、外部协议 | 多一层映射和运维 |
| 开放主机服务(Open Host Service,OHS) | 上游对外 | 上游提供稳定的服务化接口 | 核心能力被多个下游使用 | 接口版本管理成本 |
| 发布语言(Published Language) | 上游对外 | 用标准 schema/协议描述契约 | OpenAPI、Protobuf、事件 schema | 标准演进受社区约束 |
| 各行其道(Separate Ways) | 无 | 不做集成,各自实现 | 集成成本高于收益 | 功能重复 |
用法不是选一个标签贴上去,而是先判断依赖方向和谈判能力,再选保护手段。核心域如果必须依赖外部系统,优先 ACL;通用域被核心域依赖时,核心域定义端口,通用域做适配。
防腐层的最小实现
java
// 领域层只认自己的模型和端口
public record Customer(CustomerId id, String name, RiskLevel risk) {}
public interface CustomerGateway {
Customer find(CustomerId id);
}
// 基础设施层:把遗留系统的 Map 翻译成领域模型
public final class LegacyCustomerAdapter implements CustomerGateway {
private final LegacyCustomerClient client; // 返回 Map<String,Object>
public Customer find(CustomerId id) {
Map<String, Object> row = client.queryByKey(id.value());
if (row == null) throw new CustomerNotFoundException(id);
return new Customer(id,
(String) row.get("cust_name"),
RiskLevel.fromCode((String) row.get("risk_cd")));
}
}领域层看不到 cust_name、risk_cd 这些外部字段名;外部模型改字段,只改适配器。
实战场景
一、共享内核越共越大:一次改字段引发四个团队同时发版
现象:下单、履约、结算、售后四个上下文共用 trade-common(金额、订单号、状态枚举都在里面)。某次给订单状态加一个 PARTIAL_REFUNDED,四个服务都要升版本、都要回归,发布窗口从 1 个变成 4 个;期间一个服务先发了、另外三个没发,出现“状态被写入但下游不认识”的报错,回滚后重新排期。
排查:共享的东西是否真的需要共享,用变更历史来判。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 该模块的变更来源 | git log --pretty=%an 按目录统计近半年提交人所属团队 | 多个团队频繁改同一模块 → 不是共享内核,是耦合点 |
| 引用面 | 各上下文 import 该包的位置数量与用途 | 大量用于“传递数据”而非“表达概念”→ 模型外泄 |
| 枚举/状态的消费方式 | 下游是否对未知枚举值有兜底分支 | 无 UNKNOWN 兜底 → 新增枚举即破坏性变更 |
| 发布耦合度 | 一次需求涉及的服务数与共享模块版本升级次数 | 两者同步上升 → 自治性已失去 |
| 数据归属 | 四个服务是否都能写同一张表 | 共写是比共享 jar 更严重的边界问题 |
共享内核只该容纳“金额、ID”这类极小且稳定的公共概念;状态枚举属于某个上下文的内部模型,一旦共享,边界就会随每次需求生长。
对症做法:
java
// 1. 状态退回各自上下文,跨上下文只传“事实”,不传“状态”
public enum OrderStatus { /* 只在订单上下文里 */ }
public record OrderPaid(OrderId id, Money amount, Instant occurredAt) {} // 事件是事实
// 2. 下游对未知值一律有兜底,契约演进保持向后兼容
public static OrderEventKind fromWire(String raw) {
return switch (raw) {
case "ORDER_PAID" -> ORDER_PAID;
default -> UNKNOWN; // 未知不抛异常,记指标并按保守策略处理
};
}text
3. 共享包只保留值对象级别的稳定概念(Money、Quantity、Identifier),
禁止放枚举、DTO 和“大家都要用的实体”。
4. 拆数据写权限:orders 只允许订单上下文写,其他上下文读事件或读模型,见 05 篇。
5. 契约变更走“先兼容代码、再改数据”的顺序,新增枚举先全量发布消费端的 UNKNOWN 兜底。复盘要点:判据是“一次需求平均需要几个服务同时发版”和“共享包是否只含值对象”。前者应接近 1,后者可用一条架构测试守住:禁止 *-common 包中出现枚举与实体类。共享内核的代价写在映射表里——共享部分改动需双方评审,边界容易越共越大。
二、领域层出现 risk_cd:顺从者当成了防腐层
现象:核心交易域对接了 1998 年的客户主数据系统(返回 Map<String,Object>,字段名如 cust_name、risk_cd)。赶工期时直接把这套字段透传进领域对象。半年后主数据系统升级,把 risk_cd 从字符串改成两位数字编码,风控判断全线走偏,200 余笔订单被错误地走了人工审核(经验值,需校准)。
排查:边界是否被穿透,看领域层的代码就够。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 外部字段名是否进入领域层 | 在 domain/ 目录 grep cust_name、risk_cd、CUST_TABLE | 命中即边界已破 |
| 外部模型是否有翻译位置 | 是否存在实现 CustomerGateway 的适配器 | 无适配器 → 属于顺从者(Conformist) |
| 变更影响半径 | 主数据改字段后需要改动的文件数与所在层 | 散落各层 → 翻译成本被推迟到现在 |
| 语义是否被误用 | 外部 risk_cd 的取值含义是否在本域重新定义 | 直接沿用上游语义,等于把建模权交出去 |
| 上游可协商性 | 该系统的变更是否会通知、能否排期 | 不可协商且模型不可接受,就不该做顺从者 |
对症做法:
java
// 领域层只认自己的模型和端口
public record Customer(CustomerId id, String name, RiskLevel risk) {}
public interface CustomerGateway { Customer find(CustomerId id); }
// 基础设施层做翻译:外部字段变更只影响这一个类
public final class LegacyCustomerAdapter implements CustomerGateway {
private final LegacyCustomerClient client;
public Customer find(CustomerId id) {
Map<String, Object> row = client.queryByKey(id.value());
if (row == null) throw new CustomerNotFoundException(id);
return new Customer(id, (String) row.get("cust_name"),
RiskLevel.fromCode(str(row.get("risk_cd")))); // 编码在适配层归一
}
}
// 语义变化要有兜底与观测:未知编码不能默认“低风险”
public enum RiskLevel {
LOW, MEDIUM, HIGH, UNKNOWN;
public static RiskLevel fromCode(String cd) {
return switch (cd == null ? "" : cd.trim()) {
case "A", "01" -> LOW; case "B", "02" -> MEDIUM; case "C", "03" -> HIGH;
default -> UNKNOWN;
};
}
}
// 适配层对 UNKNOWN 计数并告警;业务上 UNKNOWN 走保守分支(人工复核),不放行也不拒绝text
加固顺序:
1. 先加适配层包住外部模型,再逐步把领域对象里的外部字段收回;
2. 用一条架构测试固定方向:domain 包不得依赖 legacy client 包;
3. 映射表里把这条关系明确标成 ACL,并写清翻译位置与失败策略。复盘要点:核心域依赖外部系统时优先 ACL,因为它买的是建模自由——代价是多一层映射和运维。顺从者只在“上游不可协商且模型可接受”时用;本次事故正是把一个不可接受的模型当成了可接受。判据可以机械化:grep -R "risk_cd" domain/ 结果为空,且未知编码有独立指标。
三、映射表只写了“谁调谁”,事故时没有失败策略
现象:结算上下文依赖订单上下文的 OrderPaid 事件。某天 MQ 积压 40 分钟,结算侧出现“部分账单已出、部分未出”,两边团队在会上互相要求对方“先处理”。事后翻映射表,只写了“结算 ← 订单,事件集成”,没有契约形式、翻译位置、失败策略三列。
排查:一张没有失败语义的映射表,等于没有约定。
| 缺失项 | 现场表现 | 判定证据 |
|---|---|---|
| 契约形式 | 事件字段变更未通知下游 | 无 schema 文件或 schema 不在仓库 |
| 翻译位置 | 结算消费者里直接读订单内部字段 | 上游模型改动会击穿下游 |
| 失败策略 | 积压时既没重放约定也没幂等键 | 日志里同一 orderId 多次入账 |
| 关系类型 | 说不清是 OHS 还是客户-供应商 | 变更时无人知道该谁评审 |
| 数据归属 | 两边都能更新同一张账单表 | 出现互相覆盖的更新记录 |
对症做法:把映射表补成可执行的三列。
| 下游 BC | 上游 BC | 关系 | 契约形式 | 翻译位置 | 失败策略 |
|---|---|---|---|---|---|
| 结算 | 订单 | 开放主机服务 | OrderPaid v1(JSON Schema 入库) | 结算侧 OrderEventTranslator | Outbox 保证不丢,消费幂等键 eventId;积压超 10 分钟停止出账并告警 |
| 订单 | 商品 | 客户-供应商 + ACL | 只读 API | 订单侧 ProductSnapshotAdapter | 超时降级为下单失败,不缓存脏数据 |
| 履约 | 订单 | 合作 | OrderPaid 事件 | 履约侧消费者 | 事件重放 + 幂等键,双向变更需共同排期 |
java
// 失败策略要落进代码,而不是停在表格:消费端幂等 + 积压保护
public void onOrderPaid(OrderPaid e) {
if (ledger.alreadyProcessed(e.eventId())) return; // 幂等:重复投递直接确认
txTemplate.execute(s -> { ledger.post(e); return null; });
}
// 消费延迟超阈值时暂停出账任务,宁可晚出也不出错(阈值按业务定,经验值需校准)text
开放主机服务的成本是接口版本管理:契约文件进仓库、按版本演进、旧版本保留窗口明确;
发布语言用 JSON Schema/Protobuf 描述,让“改契约”变成一次可评审的 diff。复盘要点:验收方式是演练而不是评审:断掉 MQ 40 分钟,验证结算不出错账、恢复后能追平且不重复。映射表每行都必须能回答“失败时谁降级、怎么补偿、谁负责”,答不出的行就是待办。关系选择先判断依赖方向与谈判能力,再选保护手段,见 DDD 与微服务。
最小上手
用两个产物代替「开会讨论」:上下文画布和映射表。两者都进仓库,跟代码一起评审。
上下文画布模板(每个 BC 一页):
名称:订单上下文
负责人:订单组(业务 owner + 技术 owner)
业务目的:把用户下单意图转成可履约的订单
边界内的关键术语:订单、订单项、支付状态、履约单号
核心聚合:Order
对外命令:PlaceOrder、CancelOrder、PayOrder
对外事件:OrderPlaced、OrderPaid、OrderCancelled
依赖的上游:商品(只读快照)、支付(结果回调)
依赖的下游:履约、通知、结算
不做什么:库存扣减、发票开具、物流轨迹
数据归属:orders、order_lines(只允许本上下文写)映射表模板:
| 下游 BC | 上游 BC | 关系 | 契约形式 | 翻译位置 | 失败策略 |
|---|---|---|---|---|---|
| 订单 | 商品 | 客户-供应商 + ACL | 只读 API | 订单侧 ProductSnapshotAdapter | 超时降级为下单失败,不缓存脏数据 |
| 订单 | 支付 | 合作 | 支付结果事件 | 订单侧 PaymentEventTranslator | 事件重放 + 幂等键 |
| 结算 | 订单 | 开放主机服务 | OrderPaid 事件 | 结算侧消费者 | Outbox 保证不丢,消费幂等 |
动手步骤(约 2 小时):选一条完整业务流 → 列出 10–15 个已发生的事实 → 把事实按「变更原因」聚类 → 每簇填一页画布 → 相邻画布之间填映射表。聚不出来,说明边界还没找到,不要急着写代码。
生产动作
- 画布进仓库:放在
docs/contexts/<bc>.md,和代码同一次 PR 评审。画布过期比没有画布更危险。 - 边界评审触发条件:同一术语出现第二种定义;两个团队改同一个模型文件;一个需求要改三个以上 BC。出现任一条件就重画映射,而不是加 RPC。
- 找重复模型:
rg -n "class Customer" --glob "*.java"。多个模块各有一份且字段不同,通常是 BC 已经分裂但代码没拆。 - 找变更耦合:
git log --since="6 months" --name-only --pretty=format: -- "**/domain/**" | sort | uniq -c | sort -rn | head -20。长期排在前面的模型文件,要么是核心域,要么是边界划错导致的公共垃圾场。 - 排障:一次需求改了五个服务 → 用链路追踪统计该需求的跨服务跳数与数据归属 → 若多个服务写同一张表,判定为边界未划开;先补画布和映射表,再决定拆服务,不要先加事务。
- 排障:术语在代码里漂移 →
rg -n "SubmitForm|OrderStatusNotifyDTO"→ 领域层出现传输对象命名,说明接口层模型渗进了领域层,按 04 篇的依赖规则打回。
边界
- 纯 CRUD、单体小系统:一个团队、模型稳定、没有跨团队协作,做画布和映射表的收益低于成本。可以先只保留术语表。
- BC 不是越细越好:切得太细,跨边界调用暴增,最终得到分布式单体。判断粒度看「变更是否总是一起发生」。
- 不要用组织架构直接当 BC:康威定律描述的是压力,不是设计依据。团队边界是约束条件,不是模型边界本身。
- 映射关系不是标签游戏:画了 ACL 却在代码里直接透传上游 DTO,等于没画。映射表要能指到具体的适配器类。
讲清楚
战略设计解决的是「模型在哪里成立」。先分清问题空间:核心域、支撑域、通用域,决定钱和最强的人投在哪。再把每个模型放进限界上下文:边界内术语唯一,边界外必须翻译。子域和上下文不是一一对应,一个子域可以拆多个上下文,多个小上下文也可以先合并。上下文之间用映射描述依赖:合作、共享内核、客户-供应商、顺从者、防腐层、开放主机服务、发布语言、各行其道。核心域被外部系统污染时,用防腐层把外部字段挡在门外。产出不是 PPT,是进仓库的上下文画布和映射表;没有这两样,所谓 DDD 就只是换了个包名。
初稿 2026-10 | 最后修订 2026-10