29.5 通用语言

一句话

通用语言是写进代码标识符、数据库列名、API 字段、测试名、文档标题里的那套业务词汇——凡是没有落到这五处的“术语表”,都只是 PPT;它的边界是限界上下文,不是一个公司一本词典。

语言必须在五个地方同时成立

落点 例子 不落地的后果
代码 type Order struct{}、func (o *Order) Submit() 业务说“下单”,代码叫 createOrder,评审时鸡同鸭讲
数据库 orders.status = 'submitted' 列名与状态机不一致,报表口径漂移
API POST /orders/{id}/submit 前端与外部系统被迫再学一套词
测试 TestSubmitOrder_WhenStockInsufficient_Rejected 用例名是唯一不会过期的文档,名字错了文档就错了
文档 / 图 事件风暴便利贴(29.6 事件风暴工作坊)、上下文图 图纸与代码两套语言,三个月后没人能对上

🔑 判据:一个术语如果只出现在文档里、没出现在代码里,它就不是通用语言,是文案。

一个上下文一本词典

“客户”在四个上下文里是四个东西,四个定义都对:

上下文 “客户”是什么 关键不变量 代码里的类型
会员 member 有登录凭证的人 手机号唯一 Member{ID, Phone, Nickname}
交易 trade 下单主体(可以是游客) 有收货地址或自提点 Buyer{ID, AddressID, IsGuest}
财务 billing 付款责任人 有账户与账期 Payer{ID, AccountNo, CreditTerm}
风控 risk 风险主体(可合并设备) 有风险评分与名单状态 Subject{ID, Score, DeviceIDs}

⚠️ 把四个合成一个 Customer{ID, Phone, Nickname, AddressID, IsGuest, AccountNo, CreditTerm, Score, DeviceIDs...},字段会突破 30 个,其中每个上下文常用的不超过 8 个——这就是 29.1 领域、子域、限界上下文 里那个 20 字段 Product 的同一出戏。跨上下文要翻译,用 29.2 上下文映射 (七种关系) 里的 ACL,不是统一。

术语表长什么样

列 写什么 反例
术语 规范名(中文 + 英文标识符) “订单”
定义 一句话,必须含不变量或判据 “用户下的单”(不是定义,是循环解释)
禁用同义词 明确列出不准用的词 空着(等于没约束)
代码位置 包 / 类型 / 表名 空着(三个月后找不到)
负责人 谁有权改这个定义 “架构组”(等于没人)

一条合格的词条:

术语:提交订单 submitOrder
定义:把购物车转换为一张待支付订单;成功后订单状态 = submitted,库存进入预占。
禁用:下单 / placeOrder / createOrder / 生成订单
代码:internal/trade/domain/order.go: func (o *Order) Submit()
负责人:交易域 @张三

💡 术语表的真正资产是**“禁用同义词”那一列**。词条是描述性的,禁用词是可执行的——代码评审时能直接对照,而“定义”只能靠人理解。

规模:一个上下文装多少词

术语表规模 含义 动作
30–80 个 健康:一个人能在两周内记住 保持,按迭代增量维护
80–150 个 偏大:新人上手要一个月 检查是否混进了别的上下文的词
> 150 个 几乎一定是两个上下文挤在一起 回到 29.1 重新切边界

(这三个区间是经验值,不是定律;判断依据是“新人能不能在两周内用这些词读懂代码”。)

语言漂移是可度量的

“同一个概念有四个名字”不是感觉,可以扫出来。下面这段程序把一段真实风格的代码按“禁用词”巡检一遍:

package main

import (
	"fmt"
	"regexp"
	"strings"
	"unicode"
)

// canonical:本上下文认可的规范术语
var canonical = map[string]string{
	"customer":    "客户(下单主体)",
	"product":     "商品(可售单元)",
	"submitOrder": "提交订单",
}

// banned:野生别名 -> 规范术语(这一列才是术语表的资产)
var banned = map[string]string{
	"member":      "customer",
	"user":        "customer",
	"account":     "customer",
	"acct":        "customer",
	"goods":       "product",
	"item":        "product",
	"placeOrder":  "submitOrder",
	"createOrder": "submitOrder",
}

// snippet 是一段待巡检的代码(真实项目里换成文件扫描)
const snippet = `func (s *Service) PlaceOrder(userID string, goodsID string) error {
	member := s.repo.FindUser(userID)
	acct := s.wallet.Load(member.AccountID)
	item := s.catalog.Get(goodsID)
	if item == nil {
		return ErrGoodsNotFound
	}
	return s.order.CreateOrder(member, item, acct)
}`

var identRe = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_]*`)

// norm 把标识符压成可比较的形式:小写 + 只保留字母
func norm(id string) string {
	var b strings.Builder
	for _, r := range id {
		if unicode.IsLetter(r) {
			b.WriteRune(unicode.ToLower(r))
		}
	}
	return b.String()
}

// words 把 camelCase / snake_case 拆成小写单词
func words(id string) []string {
	var out []string
	var cur strings.Builder
	flush := func() {
		if cur.Len() > 0 {
			out = append(out, cur.String())
			cur.Reset()
		}
	}
	for i, r := range id {
		switch {
		case r == '_' || unicode.IsDigit(r):
			flush()
		case unicode.IsUpper(r):
			if i > 0 {
				flush()
			}
			cur.WriteRune(unicode.ToLower(r))
		default:
			cur.WriteRune(r)
		}
	}
	flush()
	return out
}

// bannedNorm 把禁用词也规范化,否则 PlaceOrder 永远匹配不上 placeOrder
var bannedNorm = func() map[string]string {
	m := map[string]string{}
	for k, v := range banned {
		m[norm(k)] = v
	}
	return m
}()

// match 先比整个标识符(PlaceOrder),再比拆出来的词(userID -> user)
func match(id string) string {
	if t, ok := bannedNorm[norm(id)]; ok {
		return t
	}
	for _, w := range words(id) {
		if len(w) < 3 {
			continue
		}
		if t, ok := bannedNorm[w]; ok {
			return t
		}
	}
	return ""
}

type hit struct {
	line, count int
	who, target string
}

func main() {
	var found []*hit       // 用切片保序,避免 map 遍历随机
	idx := map[string]*hit{}

	for ln, line := range strings.Split(snippet, "\n") {
		for _, id := range identRe.FindAllString(line, -1) {
			target := match(id)
			if target == "" {
				continue
			}
			if h, ok := idx[id]; ok {
				h.count++
				continue
			}
			h := &hit{line: ln + 1, count: 1, who: id, target: target}
			idx[id] = h
			found = append(found, h)
		}
	}

	fmt.Println("=== 术语巡检:同一个概念被叫成了几个名字 ===")
	fmt.Printf("%-4s %-14s %-12s %-4s %s\n", "行", "标识符", "规范术语", "出现", "含义")
	concepts := map[string]bool{}
	hits := 0
	for _, h := range found {
		concepts[h.target] = true
		hits += h.count
		fmt.Printf("%-4d %-14s %-12s %-4d %s\n", h.line, h.who, h.target, h.count, canonical[h.target])
	}
	fmt.Printf("\n违规名字 %d 个,共出现 %d 次,涉及 %d 个概念\n", len(found), hits, len(concepts))
	fmt.Println("→ 每多一个名字,改名时就要多改一处;漏改一处就是线上 bug")
}

本机实测(macOS,Apple M1,Go 1.27):

=== 术语巡检:同一个概念被叫成了几个名字 ===
行    标识符            规范术语         出现   含义
1    PlaceOrder     submitOrder  1    提交订单
1    userID         customer     2    客户(下单主体)
1    goodsID        product      2    商品(可售单元)
2    member         customer     3    客户(下单主体)
2    FindUser       customer     1    客户(下单主体)
3    acct           customer     2    客户(下单主体)
3    AccountID      customer     1    客户(下单主体)
4    item           product      3    商品(可售单元)
6    ErrGoodsNotFound product      1    商品(可售单元)
8    CreateOrder    submitOrder  1    提交订单

违规名字 10 个,共出现 17 次,涉及 3 个概念
→ 每多一个名字,改名时就要多改一处;漏改一处就是线上 bug

这张表的读法:

读数 含义 动作
同一个“客户”有 5 个名字(userID、member、FindUser、acct、AccountID) 改“客户”相关字段要改 5 处,评审时还得逐个解释它们是同一个东西 一次重命名 PR 统一成 customer,并用 linter 防回归
同一个“商品”有 3 个名字(goodsID、item、ErrGoodsNotFound) 目录、库存、报表口径各写一遍 同上;注意 item 在很多系统里其实是“订单行”,先分清概念再改名
PlaceOrder 与 CreateOrder 并存 同一个动作两种命名,日志与埋点无法聚合 统一成 submitOrder,在术语表登记;这条是纯收益、零争议

10 个违规名字、17 次出现,发生在一段 9 行的示例代码里。这个密度是刻意放大的(真实服务不会每行一个别名),但它说明一件事:别名一旦进了代码,就是逐行扩散的——评审时你看的是单行 diff,永远看不到“同一个概念已经有了 5 个名字”这个全局事实。这正是必须用工具巡检的原因。

🔑 把这张巡检表接进 CI(52.2 静态检查),术语漂移就从“靠人盯”变成“提交即拦截”。规则可以很便宜:一个禁用词表 + 一段正则,30 行代码。

中文团队特有的三个坑

坑 现象 解法
中英混用 同一个动作三个名字:下单 / placeOrder / createOrder 术语表固定“中文名 + 唯一英文标识符”,两者一一对应
翻译不一致 履约 / 交付 / 发货 / 配送 混用;退款 / 退货 / 售后 混用 动词也要进术语表,只登记名词是不够的
业务改词不同步改代码 业务把“套餐”改叫“权益包”,代码还是 package(还是 Go 关键字) 改名走 RFC(58.1 ADR 实践)+ 数据库列名同步迁移(55.4 数据库变更管理)

命名的三条硬规则

  1. 业务词优先于技术词:Order.Submit() 而不是 Order.UpdateStatusToSubmitted()——后者把状态机的实现细节写进了名字。
  2. 动词唯一:一个业务动作只有一个动词。提交订单 就叫 submit,不要同时存在 place / create / confirm / generate。
  3. 状态名与状态机一致:代码里的 StatusSubmitted、数据库里的 submitted、看板上的“待支付”,必须是同一套枚举的三个投影,不能各写一套。

反模式

反模式 后果
术语表放在 Confluence,写完没人看 三个月后与代码完全脱节,还不如没有
术语表由架构师一个人写 词是“设计出来的”,不是“业务说出来的”,团队不认
同一个上下文里一个词两个定义 “订单状态”既是支付状态又是履约状态,状态机画不出来
用 UI 文案当领域术语 文案会随 A/B 实验改,模型跟着改,永远追不上
强行全公司统一术语 造出 30+ 字段的巨型实体,所有上下文互相耦合(26.6 模块化与包设计 里同样的病)
只在领域层用通用语言,infra 层随便叫 domain.Order 映射到 dao.OrderPO,转换层里两套名字,bug 都藏在这一层
术语表只登记名词 动词漂移(submit / place / create)无人管,恰恰是最高频的漂移

自检

Q1 一个新人问:“为什么会员上下文叫 Member,交易上下文叫 Buyer,财务叫 Payer,它们不都是客户吗?统一成 Customer 不是更清楚?”怎么回答?

▸ 答案:统一会更糊涂,因为三者的不变量不同。Member 的不变量是手机号唯一(登录凭证);Buyer 允许游客、必须有收货地址;Payer 有账户与账期。合成一个 Customer 后,每个上下文都要面对一堆“对我无意义但必须维护”的字段,且任何一个上下文改字段都会影响另外两个。通用语言的边界就是限界上下文——同一个词在不同上下文有不同定义,是设计,不是混乱;混乱的是同一个上下文里有两个定义。

Q2 为什么说“禁用同义词”这一列比“定义”那一列更有价值?

▸ 答案:定义是给人理解的,禁用词是给机器和评审执行的。定义写得再好,评审时也只能靠“我觉得这算不算同一个东西”;禁用词表可以直接做成 linter(本节 30 行代码),在 CI 里拦截新增的 placeOrder。而且禁用词表天然暴露了漂移现状——你能列出 4 个禁用词,说明这个概念的 4 个名字已经存在于代码里。

Q3 术语表超过 150 个词,为什么第一反应是“检查边界”,而不是“精简词条”?

▸ 答案:因为一个限界上下文的前提是“一个人能在合理时间内掌握这套语言”。词条规模是上下文复杂度的直接度量:30–80 个是一个团队能共同记住的量级,150 以上意味着这个上下文里塞进了多个业务领域,成员各自只熟悉一部分,评审时必然出现“这个词我不认识但不敢问”。精简词条只能删掉文档,删不掉代码里的概念;正确动作是回到 29.1 按语言判据重新切边界。

Q4 巡检程序把 AccountID 判成“客户”的别名,这一定对吗?如果不对,该怎么改规则?

▸ 答案:不一定对。AccountID 在财务上下文里可能真的是“账户”,而在交易上下文里只是会员的账户号(即客户标识)。这说明禁用词表必须按上下文维护:同一个词在 A 上下文合法、在 B 上下文违规。改法是给 linter 加一个“作用域”维度(包路径 → 禁用词表),而不是把 account 从全局表里删掉——删掉就等于放弃这条规则,加作用域才既精确又可执行。

关联