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 架构图规范)不是服务调用图,必须标出:

  1. 方向:U(upstream)/ D(downstream),箭头从上游指向下游;
  2. 关系类型:七种之一,写在箭头上;
  3. 团队名:关系本质是团队关系,只写系统名等于没写。

⚠️ 画成“服务调用图”的映射图会丢掉最关键的信息——谁让步。丢掉这一条,图上所有箭头都无法指导任何决策,最后还是会有人问“他们改字段能不能先通知我们”。

反模式

反模式 后果
所有上下文之间都用共享内核 编译期耦合全网,任何改动都触发全体重编与联合发布,退化成大泥球
声称有 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 的职责正是把“上游世界的各种脏数据”挡在边界外,只让合法的领域对象进入。

关联