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 数据库变更管理) |
命名的三条硬规则
- 业务词优先于技术词:
Order.Submit()而不是Order.UpdateStatusToSubmitted()——后者把状态机的实现细节写进了名字。 - 动词唯一:一个业务动作只有一个动词。
提交订单就叫submit,不要同时存在place/create/confirm/generate。 - 状态名与状态机一致:代码里的
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 从全局表里删掉——删掉就等于放弃这条规则,加作用域才既精确又可执行。