29.2 上下文映射 (七种关系)
一句话
上下文映射不是画集成拓扑,而是把“两个模型冲突时谁让步”写成一张权力关系图——七种关系就是七种让步方式,选错一种,精心画出的上下文边界会在半年内退化成共享数据库。
七种关系一张表
| # | 关系 | 方向 | 谁让步 | 变更传导 | 代价 | 什么时候用 |
|---|---|---|---|---|---|---|
| 1 | 合作关系 Partnership | 双向 | 双方共同让步 | 立即(联合发布) | 联合排期、联合发布 | 两个上下文同生共死,且两个团队天天能沟通 |
| 2 | 共享内核 Shared Kernel | 双向 | 共用一小块模型 + 代码 | 编译期 | 改共享部分要双方同时发布 | 共享的是极小且极稳定的不可变值对象(Money、单位、ID 类型) |
| 3 | 客户-供应商 Customer-Supplier | 上游 → 下游 | 上游优先满足下游需求 | 快(可协商) | 需求池排序谈判 | 下游有话语权(同一家公司、下游是收入来源) |
| 4 | 遵奉者 Conformist | 上游 → 下游 | 下游全盘接受上游模型 | 快(被动跟随) | 下游领域模型被上游语义污染 | 上游强势且改不动(集团平台、银行/税局接口),自建翻译不值得 |
| 5 | 防腐层 ACL | 上游 → 下游 | 都不让步,中间加翻译 | 慢(只影响翻译层) | 一套 DTO + 映射 + 契约测试 | 默认选择:外部系统、遗留系统、上游模型难看 |
| 6 | 分离方式 Separate Ways | 无 | 各干各的 | 无 | 功能重复实现 | 集成成本 > 重复成本(两边都只是发个短信) |
| 7 | 开放主机服务 OHS + 发布语言 PL | 上游 → N 个下游 | 上游定义公开契约 | 慢(靠版本兼容) | 上游要维护对外契约与版本策略 | 一个上游要服务 ≥3 个下游 |
💡 “七种”的常见争议:Evans 原文把 OHS 与 PL 分开列,数出来是 8 个模式;实际落地时两者成对出现(OHS = 一份稳定接口,PL = 接口上跑的那套语言),合起来算一种才凑成七种。还有一种“大泥球 Big Ball of Mud”,Evans 明确把它列为 non-pattern——它不是关系,是“没有映射”的失败态。
最重要的排序:变更传导速度
七种关系的核心差异不是“技术上怎么连”,而是“上游一改,下游多久被迫改”:
共享内核 > 合作关系 > 遵奉者 > 客户-供应商 > OHS+PL > 防腐层 > 分离方式
(编译不过)(联合发布)(跟着升级) (排期协商) (版本兼容)(只改翻译层)(无影响)
🔑 选关系时只问一句:这个上游一年会变几次? 一年变 0 次且共享的是值对象 → 共享内核;一年变 3 次以上且你管不了它 → 防腐层,没有第二种正确答案。
选型决策表
| 你的情况 | 选什么 | 为什么 |
|---|---|---|
| 上游是外部 SaaS / 支付 / 税务接口,还会自己升级 | ACL | 你无法谈判,只能翻译;这一步没有替代方案 |
| 上游是自家团队,下游是收入主线,下游有话语权 | Customer-Supplier | 用排期换一致性,比翻译层便宜 |
| 上游是平台团队,下游有 5 个以上 | OHS + PL | 让上游出一份稳定契约,5 个下游共用一个版本 |
| 上游是集团统一平台 / 银行接口,改不动,模型还算能忍 | Conformist | 承认污染,把它隔离在边界上,别假装有 ACL |
| 上下游同团队、同发布节奏、同生共死 | Partnership,或者干脆合并成一个上下文 | 两个上下文 + 合作关系 = 一个上下文加两层税 |
| 只共享 Money、时间、单位、ID 类型 | 共享内核(只放不可变值对象) | 复制比共享贵,且这类值对象几乎不变 |
| 集成成本 > 重复实现成本 | Separate Ways | 两个上下文都要“发短信”,就别为它建管道 |
| 上游是遗留系统,改不动,模型又难看 | ACL(不是 Conformist) | 改不动 ≠ 该被污染,翻译层正是为这种情况发明的 |
代码:ACL 到底挡住了什么
上游换了一版接口(金额从字符串改成 int 分、字段改名),下游领域模型应该一行都不用改。这就是 ACL 唯一值得写的理由。
package main
import (
"errors"
"fmt"
"strconv"
"strings"
)
// ---------- 下游 billing 上下文的领域模型:不认识上游任何类型 ----------
type Money struct {
Cents int64
Currency string
}
type Payer struct {
ID string
Name string
}
type Account struct {
Payer Payer
Balance Money
}
// Charge 是领域行为:只认 Money,上游格式怎么变都与它无关
func (a *Account) Charge(m Money) (Money, error) {
if m.Currency != a.Balance.Currency {
return Money{}, errors.New("币种不一致")
}
if m.Cents <= 0 {
return Money{}, errors.New("金额必须为正")
}
if a.Balance.Cents < m.Cents {
return Money{}, errors.New("余额不足")
}
a.Balance.Cents -= m.Cents
return a.Balance, nil
}
// ---------- 上游 legacy:v1 是带千分位的字符串,v2 改成 int 分并改了字段名 ----------
type LegacyV1 struct {
ID string
Balance string // "1,234.50"
Name string // "张 三"
Currency string
}
type LegacyV2 struct {
ID string
BalanceCents int64
FullName string
Currency string
}
// ---------- ACL:唯一知道上游格式的地方 ----------
func translateV1(in LegacyV1) (Account, error) {
cents, err := parseYuanToCents(in.Balance)
if err != nil {
return Account{}, fmt.Errorf("acl v1: 余额 %q 无法解析: %w", in.Balance, err)
}
if in.Currency != "CNY" {
return Account{}, fmt.Errorf("acl v1: 不支持的币种 %q", in.Currency)
}
return Account{
Payer: Payer{ID: in.ID, Name: strings.Join(strings.Fields(in.Name), "")},
Balance: Money{Cents: cents, Currency: "CNY"},
}, nil
}
func translateV2(in LegacyV2) (Account, error) {
if in.Currency != "CNY" {
return Account{}, fmt.Errorf("acl v2: 不支持的币种 %q", in.Currency)
}
if in.BalanceCents < 0 {
return Account{}, errors.New("acl v2: 余额为负")
}
return Account{
Payer: Payer{ID: in.ID, Name: strings.Join(strings.Fields(in.FullName), "")},
Balance: Money{Cents: in.BalanceCents, Currency: "CNY"},
}, nil
}
// parseYuanToCents 不用 float 解析金额:0.1+0.2 那一课在钱上代价最高
func parseYuanToCents(s string) (int64, error) {
s = strings.ReplaceAll(strings.TrimSpace(s), ",", "")
neg := false
if strings.HasPrefix(s, "-") {
neg, s = true, s[1:]
}
intPart, frac, _ := strings.Cut(s, ".")
if frac == "" {
frac = "00"
}
if len(frac) > 2 {
return 0, errors.New("精度超过 2 位小数")
}
for len(frac) < 2 {
frac += "0"
}
i, err := strconv.ParseInt(intPart, 10, 64)
if err != nil {
return 0, err
}
f, err := strconv.ParseInt(frac, 10, 64)
if err != nil {
return 0, err
}
c := i*100 + f
if neg {
c = -c
}
return c, nil
}
func main() {
fmt.Println("=== 上游 v1(字符串金额)→ 域内 Money(分)===")
for _, in := range []LegacyV1{
{ID: "A-1", Balance: "1,234.50", Name: "张 三", Currency: "CNY"},
{ID: "A-2", Balance: "0.05", Name: "李 四", Currency: "CNY"},
{ID: "A-3", Balance: "1.234", Name: "王 五", Currency: "CNY"},
} {
acc, err := translateV1(in)
if err != nil {
fmt.Printf("%-4s -> ACL 拒绝: %v\n", in.ID, err)
continue
}
fmt.Printf("%-4s -> %6d 分, Payer.Name=%q\n", acc.Payer.ID, acc.Balance.Cents, acc.Payer.Name)
if left, err := acc.Charge(Money{Cents: 9999, Currency: "CNY"}); err != nil {
fmt.Printf(" Charge(99.99) 被拒绝: %v\n", err)
} else {
fmt.Printf(" Charge(99.99) 后余额 %d 分\n", left.Cents)
}
}
fmt.Println("\n=== 上游 v2(int 分 + 字段改名)→ 同一个 Money ===")
for _, in := range []LegacyV2{
{ID: "A-1", BalanceCents: 123450, FullName: "张 三", Currency: "CNY"},
{ID: "A-9", BalanceCents: 100, FullName: "赵 六", Currency: "USD"},
} {
acc, err := translateV2(in)
if err != nil {
fmt.Printf("%-4s -> ACL 拒绝: %v\n", in.ID, err)
continue
}
fmt.Printf("%-4s -> %6d 分, Payer.Name=%q\n", acc.Payer.ID, acc.Balance.Cents, acc.Payer.Name)
}
fmt.Println("\n领域代码(Account / Money / Charge)在两次上游升级中改动 0 行")
}
本机实测(macOS,Apple M1,Go 1.27):
=== 上游 v1(字符串金额)→ 域内 Money(分)===
A-1 -> 123450 分, Payer.Name="张三"
Charge(99.99) 后余额 113451 分
A-2 -> 5 分, Payer.Name="李四"
Charge(99.99) 被拒绝: 余额不足
A-3 -> ACL 拒绝: acl v1: 余额 "1.234" 无法解析: 精度超过 2 位小数
=== 上游 v2(int 分 + 字段改名)→ 同一个 Money ===
A-1 -> 123450 分, Payer.Name="张三"
A-9 -> ACL 拒绝: acl v2: 不支持的币种 "USD"
领域代码(Account / Money / Charge)在两次上游升级中改动 0 行
这段输出里藏着三个判断:
| 现象 | 说明 |
|---|---|
"1.234" 被拒 | 校验位置在 ACL,不在领域层。领域层收到的永远是合法的 Money,它的 Charge 不需要再写“金额格式对不对” |
v2 换了字段名与类型,Charge 一行没改 | 上游变更半径被 ACL 截断在翻译层 |
USD 在 v1/v2 都被拒 | 币种是契约约束,不是“以后再说”——ACL 是唯一能集中说“不”的地方 |
建 ACL 的成本与收益,算一遍
估算口径(假设值,可换成你自己的):上游一年改 3 次格式,下游有 5 个上下文,没有 ACL 时每次要改 2 人日/上下文;有 ACL 时每次只改 0.5 人日/上下文,但每个上下文要先花 1 人日建翻译层。
| 方案 | 一次性投入 | 每年维护 | 三年总成本 |
|---|---|---|---|
| 无 ACL(直连上游模型) | 0 | 3 × 2 × 5 = 30 人日 | 90 人日 |
| 有 ACL | 1 × 5 = 5 人日 | 3 × 0.5 × 5 = 7.5 人日 | 27.5 人日 |
💡 遗留系统的整体替换路径(绞杀者 + 防腐层)是 28.5 遗留系统集成 (绞杀者 / 防腐层)(待补)的主场,ACL 是其中最小的一块。
🔑 结论:按本表假设(每年 3 次),三年累计 27.5 人日 vs 90 人日。把频率降到每年 1 次,第一年仍是 7.5 vs 10,也已经回本——只要上游会变,ACL 就划算;一年都不改的上游建 ACL 才是过度设计,但它一年改几次,你说了不算,上游说了算。这就是为什么对第三方接口默认建 ACL。
组合关系:OHS+PL 与 ACL 是一对
七种关系不是七选一,常见的正确组合是:
上游(发布方) 下游 A 下游 B
OHS + PL ────────────→ ACL ─→ 领域模型 ACL ─→ 领域模型
(一份稳定契约、版本策略) (各自翻译) (各自翻译)
上游用 OHS+PL 保证“我对外只有一份契约、按版本演进”,下游各自用 ACL 保证“我的模型不被污染”。两个都做,才叫解耦;只做一个,是单方面妥协:
| 只做 OHS+PL | 只做 ACL |
|---|---|
| 上游契约稳定了,但下游仍被迫用上游的字段名与语义,模型慢慢变形 | 下游模型干净了,但上游接口仍在无版本地乱改,翻译层天天修 |
映射图上必须画的三样东西
上下文映射图(画法规范见 80.2 架构图规范)不是服务调用图,必须标出:
- 方向:U(upstream)/ D(downstream),箭头从上游指向下游;
- 关系类型:七种之一,写在箭头上;
- 团队名:关系本质是团队关系,只写系统名等于没写。
⚠️ 画成“服务调用图”的映射图会丢掉最关键的信息——谁让步。丢掉这一条,图上所有箭头都无法指导任何决策,最后还是会有人问“他们改字段能不能先通知我们”。
反模式
| 反模式 | 后果 |
|---|---|
| 所有上下文之间都用共享内核 | 编译期耦合全网,任何改动都触发全体重编与联合发布,退化成大泥球 |
| 声称有 ACL,实际把上游 JSON 直接透传给领域层 | 隔离失效,上游改字段时领域代码照样全改,白付翻译层成本 |
| 对自家同团队的两个模块也建 ACL | 翻译层代码量超过业务代码,成本全花在搬运字段 |
| 用 Conformist 但嘴上说 ACL | 领域模型里出现 upstream_status_code、ext_json 这类字段,污染不可逆 |
| 上游对 10 个下游各发一套私有接口 | 上游维护 10 套契约,版本矩阵爆炸;应该是 OHS+PL |
| 核心链路用 Separate Ways(订单与库存各存一份库存) | 双写必然不一致,超卖(82.1 超卖与资损(待补)) |
| 用“合作关系”逃避定边界 | 两个上下文永远同步发布,实际上是一个系统,多付了一次拆分成本 |
自检
Q1 上游是集团统一提供的用户中心,接口每年改一次,字段名叫 usr_st、acct_tp,你们改不动它。该用 Conformist 还是 ACL?为什么?
▸ 答案:用 ACL。Conformist 的判据不是“改不动”,而是“改不动且上游模型可以接受”。usr_st、acct_tp 这种命名与你们通用语言完全对不上,直接跟随会让领域模型里出现一堆无法解释的缩写,规则也会被上游语义带偏。ACL 的判据只有一个:上游模型与你的模型不一致。改不动只是让你更没有谈判空间,反而更该翻译。若上游同时提供 OHS+PL(稳定契约 + 版本),下游 ACL 是标准组合。
Q2 为什么“变更传导速度”比“耦合度”更适合用来给七种关系排序?
▸ 答案:耦合度是静态结构指标,回答“连了几个东西”;变更传导速度是动态成本指标,回答“一年要为此加几次班”。上下文映射的决策目标是控制变更成本,而不是追求连接数最少。同一对上下文,“共享内核”和“ACL”在图上都只是一条线,但一次上游改字段,前者编译就断、双方联合发布,后者只改一个翻译函数——这才是选型要比较的量。
Q3 一个团队说“我们和上游是合作关系,所以每次发布都一起发”。这句话有什么风险?
▸ 答案:合作关系是七种关系里耦合最高的一种(仅次于共享内核),它意味着双方的发布节奏被绑死,任何一方都无法独立交付。如果两个上下文其实可以独立演进,用合作关系等于自愿放弃独立发布能力——而这正是拆分上下文的主要收益。正确做法是先问“能不能降级成 Customer-Supplier(排期协商)或 OHS+PL(版本兼容)”;如果确实同生共死,那更该考虑合并成一个上下文,省掉两份发布流程。
Q4 ACL 把 "1.234" 拒掉了,这个校验为什么不放在领域层的 Charge 里?
▸ 答案:因为“上游金额格式是否合法”是集成问题,不是业务规则。领域层的 Money 已经用 int64 分表达金额,它在类型上就不可能带三位小数;把格式校验放进领域层,等于让领域模型知道上游的字符串格式,隔离就破了。ACL 的职责正是把“上游世界的各种脏数据”挡在边界外,只让合法的领域对象进入。
关联
- 前置:29.1 领域、子域、限界上下文、26.2 依赖倒置
- 并列:26.5 六边形架构、53.5 契约测试
- 下游:29.7 从模型到服务边界、80.2 架构图规范
- 待补:82.1 超卖与资损(未写,无链接)
- 下游:28.5 遗留系统集成 (绞杀者 / 防腐层)