DDD 实战:从领域划分到微服务落地#
很多团队做微服务拆分时,第一反应是"按表拆"“按功能拆”,结果服务越拆越细,调用链越来越长,事务一致性越来越难,业务逻辑散落在四五个服务里。问题往往不在技术,而在边界没划清。更要命的是,这种拆分几乎不可逆——一旦下游服务消费了你的接口契约,重新划边界的成本会随着接入方数量指数上升。DDD 的价值,正是提供一套从业务出发划分边界、组织模型的方法,让边界在写第一行代码之前就被论证清楚。
一、DDD 是什么:它到底在对付哪种复杂度#
软件系统的复杂度大致有三个来源:
| 复杂度类型 | 来源 | 典型应对手段 |
|---|---|---|
| 业务复杂度 | 业务规则多、例外多、语义歧义大、变化快 | 领域建模、限界上下文、通用语言 |
| 技术复杂度 | 高并发、分布式、存储选型、性能瓶颈 | 架构模式、中间件、容量设计 |
| 规模复杂度 | 人多、模块多、协作链路长 | 团队拓扑、康威定律、平台工程 |
DDD 的靶心是第一种。它不解决"如何扛住 10 万 QPS",也不解决"如何让 50 个团队不互相阻塞"——虽然它对后者有影响。很多团队用 DDD 去解决技术复杂度,最后只得到了一堆没人看得懂的分层和抽象。
一句话概括 DDD 的定位:当"业务怎么说"与"代码怎么写"之间的鸿沟成为主要成本时,DDD 才值得上。
它通常分为两部分:
| 层次 | 解决的问题 | 核心产出 | 失败代价 |
|---|---|---|---|
| 战略设计 | 边界在哪 | 子域、限界上下文、通用语言、上下文映射 | 边界错了,模型再精致也没用,且重构成本极高 |
| 战术设计 | 边界内怎么建模 | 实体、值对象、聚合、领域服务、领域事件、仓储 | 模型贫血,业务规则散落,但边界仍可保留 |
flowchart TB
subgraph PS["问题空间(业务侧)"]
D["领域(业务全景)"] --> SD1["核心域"]
D --> SD2["支撑域"]
D --> SD3["通用域"]
end
subgraph SS["解决方案空间(系统侧)"]
BC1["限界上下文 A"]
BC2["限界上下文 B"]
BC1 <-->|"上下文映射"| BC2
end
SD1 -->|"战略设计:划边界"| BC1
SD2 -->|"战略设计:划边界"| BC2
BC1 -->|"战术设计:建模型"| M["实体 / 值对象 / 聚合 / 领域事件 / 仓储"]
BC1 -.->|"通用语言双向校准"| D战略设计决定系统怎么拆,战术设计决定拆完之后怎么建。 很多团队只学战术设计,一上来就研究聚合根、值对象,却忽略了限界上下文——结果是模型建得很精致,但服务边界依然混乱,模型放错了地方。战略设计比战术设计更重要,也更难,因为它没有编译器帮你检查。
二、战略设计:先在问题空间想清楚#
战略设计的本质是在两个空间里各做一次切分:在问题空间切出子域,在解决方案空间切出限界上下文。二者的关系是整个 DDD 最容易混淆的地方。
1. 领域与子域:问题空间的切分#
领域是业务问题空间本身,子域是对领域的自然切分,通常按业务能力划分。注意:子域是业务视角的划分,与"你们公司有几个开发组"“数据库里有多少张表"无关。
子域分三类,分类的意义在于决定投入策略——这是 DDD 里少数能直接指导资源分配的部分:
| 类型 | 含义 | 投入策略 | 判断信号 |
|---|---|---|---|
| 核心域 | 公司竞争力的来源,决定差异化 | 自研,投入最强资源与最好的人 | 做不好会失去竞争力;同行做法差异大;变化快 |
| 支撑域 | 业务必需,但不构成核心竞争力 | 自研或采购,追求稳定与低成本 | 必须做,但做成什么样客户不关心 |
| 通用域 | 行业通用能力 | 直接买或开源,不要自研 | 同行做法基本一致;有成熟商业方案 |
判断核心域的方法很简单:这件事做不好,公司会不会失去竞争力? 会,就是核心域。
但要注意两个常见误判:
- 把支撑域当核心域自研:比如自建 IM、自建工作流引擎、自建权限系统,投入巨大但产生不了差异化,最后拖垮主干研发。
- 把核心域当支撑域外包:核心域恰恰是不能外包的部分,因为它的知识沉淀就是竞争力本身。
再补一个动态视角:核心域会漂移。一个子域今天是不是核心域,取决于公司的战略。电商早期交易是核心域,随着履约网络成为壁垒,履约也可能变成核心域。所以核心域判断应该是一个定期重审的结论,不是一次性标签。
2. 限界上下文:解决方案空间的边界#
限界上下文是 DDD 中最重要的概念之一,也是微服务拆分的直接依据。
它的核心规则是:
一个限界上下文内部,所有术语必须有且仅有一个明确含义。
例如"商品"这个词:
- 在交易上下文里,商品是"可售 SKU + 价格 + 库存快照”;
- 在履约上下文里,商品是"物理货物 + 库位 + 包裹";
- 在售后上下文里,商品是"保修对象 + 服务期限"。
同一个词,不同含义,就说明它们属于不同的限界上下文。
限界上下文实际上承担了三重边界,这也是它比"模块划分"重得多的原因:
| 边界维度 | 含义 | 越界的后果 |
|---|---|---|
| 语言边界 | 同一术语在此内只有一个含义 | 需求评审时各说各话,代码里出现 Order 与 Trade 混用 |
| 模型边界 | 模型只服务于本上下文的业务规则 | 一个 User 类里塞进登录、会员、客服、风控四套字段 |
| 事务边界 | 跨上下文不追求强一致 | 分布式事务满地跑,锁竞争与失败重试拖垮链路 |
3. 子域 ≠ 限界上下文#
这是实践中最容易踩的坑。子域在问题空间(业务怎么说),限界上下文在解决方案空间(系统怎么建),二者不是天然一一对应的:
flowchart LR
subgraph OS["问题空间:子域"]
A1["子域 A:交易"]
A2["子域 B:履约"]
end
subgraph SS["解决方案空间:限界上下文"]
B1["交易上下文"]
B2["拣货上下文"]
B3["配送上下文"]
end
A1 -->|"1:1"| B1
A2 -->|"1:N:技术/团队/伸缩性驱动拆分"| B2
A2 --> B3常见的四种映射形态:
| 形态 | 何时出现 | 风险 |
|---|---|---|
| 1 子域 : 1 上下文 | 最理想,业务与系统边界一致 | 无 |
| 1 子域 : N 上下文 | 团队规模大、伸缩性诉求不同、读写负载差异大 | 上下文间协调成本上升 |
| N 子域 : 1 上下文 | 业务能力很弱、合并成本低于拆分成本 | 模型容易互相污染,需警惕 |
| 1 子域 : 0 上下文 | 该子域已采购或尚未启动 | 需在上下文映射里标注为外部系统 |
判断口诀:子域的划分只能来自业务,限界上下文的划分可以再叠加技术与组织因素。
4. 通用语言:不只是术语表#
通用语言是团队和代码共享的一套词汇表。它必须贯穿需求、设计、代码、测试和文档。
如果业务方说"取消订单",代码里却叫 deleteOrder,这就是语言断裂。而且断裂会沿着链路扩散:
| 环节 | 业务方表述 | 断裂后的代码 | 后果 |
|---|---|---|---|
| 需求 | 取消订单 | deleteOrder | 取消是状态流转,删除是物理消失,语义完全不同 |
| 接口 | 取消订单 | POST /order/remove | 契约无法表达"可取消"的约束 |
| 存储 | 取消订单 | DELETE FROM orders | 审计与对账数据永久丢失 |
| 测试 | 取消订单 | testDelete() | 测试用例名无法与业务用例对齐 |
通用语言不是写一份术语表就完事,而是要反映在代码命名、接口定义和数据库字段里。一套可执行的校准机制:
- 术语表双向维护:业务侧的名词与代码里的类型名一一映射,新增术语必须同时更新两侧;
- 命名即评审项:Code Review 时,
OrderCancelReason是否对应业务说的"取消原因"是一等公民的评审点; - 测试即文档:用例名直接使用业务原话,
Test_取消订单_已发货时应当拒绝; - 语言断裂即重构信号:发现同一个业务概念出现两个名字,或同一个名字承担两种含义,优先质疑边界,而不是加一个转换层糊过去。
5. 上下文映射:9 种模式与背后的权力关系#
划分完上下文后,还要定义它们之间的关系。上下文映射不只是技术集成方案,它同时是一份团队协作与谈判结果的记录——上游是谁、谁有话语权,决定了你该用哪种模式。
| 模式 | 含义 | 适用场景 | 代价 |
|---|---|---|---|
| 共享内核 | 多个上下文共享一小部分模型 | 两个团队高度协同、模型稳定 | 任一方改动都会牵动另一方,需严格管控 |
| 客户-供应商 | 上游按下游需求调整接口 | 上下游有明确的服务关系 | 上游需为下游排期,下游有等待成本 |
| 遵奉者 | 下游直接遵循上游模型,不做适配 | 上游强势且模型够用(如云厂商 API) | 下游模型被上游绑架 |
| 防腐层 | 下游加一层转换,隔离上游模型变化 | 上游模型与本地语义差异大,或上游不稳定 | 多一层维护与映射成本 |
| 开放主机服务 | 上游提供统一协议供多方调用 | 一上游对多下游 | 协议设计需足够稳定、版本管理复杂 |
| 发布语言 | 用统一格式发布事件或数据 | 跨上下文异步集成 | 需要schema治理与兼容性策略 |
| 各行其道 | 两个上下文没有直接关系 | 业务上确实无关 | 可能重复建设 |
| 合作关系 | 双方共同成功或共同失败,互相排期 | 强耦合的两块业务 | 沟通成本高,一方延期拖累全局 |
| 大泥球 | 无边界、无规则的共享模型 | 无(应避免) | 任何改动都可能引发不可预知的影响 |
上游与下游的权力关系,可以归纳成这张判断图:
flowchart TB
Q1{"上游是否愿意<br/>为下游调整?"}
Q1 -->|"愿意且双方对等"| P1["合作关系 / 共享内核"]
Q1 -->|"愿意,但上游主导"| P2["客户-供应商"]
Q1 -->|"不愿意,上游强势"| Q2{"上游模型是否<br/>基本可用?"}
Q2 -->|"可用"| P3["遵奉者"]
Q2 -->|"差异大或不稳定"| P4["防腐层(ACL)"]
Q1 -->|"上游服务多方"| P5["开放主机服务 + 发布语言"]一个典型的电商上下文地图:
flowchart LR
Product["商品上下文"]
Trade["交易上下文"]
Settle["结算上下文"]
Fulfill["履约上下文"]
After["售后上下文"]
Pay["第三方支付"]
Logistics["第三方物流"]
Product -->|"OHS + PL:SKU 事件"| Trade
Trade -->|"Customer-Supplier:订单事件"| Fulfill
Trade -->|"Customer-Supplier:应付凭证"| Settle
Settle -->|"ACL:隔离支付网关模型"| Pay
Fulfill -->|"ACL:隔离物流商模型"| Logistics
After -.->|"Shared Kernel:售后单与订单号"| Trade读图要点:凡是外部不可控的系统(支付、物流),一律用防腐层挡住;凡是自己在主导的上下文之间,用事件解耦;共享内核只用在真正需要强协同的地方,且范围要小。
避免外部模型污染是防腐层的首要职责,它的代码骨架长这样:
// internal/biz/payment_gateway.go —— 领域层定义的端口
type PaymentGateway interface {
Pay(ctx context.Context, orderNo string, amount Money) (PaymentID, error)
}
// internal/data/payment_gateway.go —— 基础设施层的实现(防腐层)
type alipayGateway struct {
client *alipay.Client
}
func (g *alipayGateway) Pay(ctx context.Context, orderNo string, amount Money) (PaymentID, error) {
// 上游模型 -> 本地模型 的转换集中在此处,不外泄
resp, err := g.client.TradePay(ctx, &alipay.TradePayRequest{
OutTradeNo: orderNo,
TotalAmount: amount.YuanString(), // 第三方用"元"字符串,本地用"分"整数
})
if err != nil {
return "", mapAlipayError(err) // 把上游错误码翻译成领域语义错误
}
return PaymentID(resp.TradeNo), nil
}注意最后一行:错误也要翻译。把 ACQ.TRADE_HAS_SUCCESS 这类上游错误码直接抛到业务代码里,等于把上游的语言灌进了领域层,防腐层就白加了。
三、领域如何划分:从业务能力到边界#
领域划分不是拍脑袋,可以按以下步骤推进:
- 业务能力盘点:和业务方一起列出所有业务能力,而不是先看数据库表。开场问题应该是"公司靠什么赚钱",而不是"有哪些系统"。
- 区分核心域、支撑域、通用域:确定投入优先级。
- 找语言边界:同一个词在不同业务里含义不同,就是拆分信号。
- 看变化频率:变化节奏一致的东西放一起,变化节奏不同的分开。频繁变化的部分不应污染稳定的部分。
- 看团队边界:康威定律绕不开,一个限界上下文最好由一个团队负责。
- 看事务边界:一个聚合的修改应在一个事务内完成。如果两个子域需要强一致事务,边界可能划错了。
- 看伸缩性需求:读写比、峰值 QPS、延迟敏感度差异巨大的两块能力,分开部署更划算。
- 看合规与安全边界:数据分级、跨境合规、审计要求,往往天然形成上下文边界。
- 用事件风暴工作坊验证:通过领域事件、命令、聚合、策略,把业务流程和边界可视化。
1. 事件风暴:三个阶段的产出不同#
事件风暴常被当成"贴便利贴的团建",其实它是有明确阶段和产出的。
flowchart LR
A["阶段一:Big Picture<br/>贴领域事件,还原业务流程"] --> B["阶段二:Process Level<br/>补齐命令—事件—策略闭环"]
B --> C["阶段三:Design Level<br/>识别聚合与限界上下文"]
C --> D["产出:上下文地图<br/>+ 聚合清单 + 关键领域事件"]| 阶段 | 参与者 | 关键产出 | 常见错误 |
|---|---|---|---|
| Big Picture | 业务 + 产品 + 研发 | 按时间轴排列的领域事件墙 | 陷入细节争论,纠结字段与表结构 |
| Process Level | 产品 + 研发 | 命令、事件、策略(Policy)闭环,热点与瓶颈 | 只有"正常流程",忽略补偿与异常分支 |
| Design Level | 研发 | 聚合、不变量、限界上下文、上下文映射 | 直接跳到技术实现,忽略语言一致性检查 |
阶段二里最容易被漏掉的是策略(Policy)——即"当某事件发生时,应当触发什么命令"。策略恰恰是跨聚合、跨上下文协作的落点:
flowchart LR
C1["命令:提交订单"] --> E1["事件:订单已提交"]
E1 --> P1{"策略:库存是否充足?"}
P1 -->|"充足"| C2["命令:锁定库存"]
P1 -->|"不足"| C3["命令:取消订单"]
C2 --> E2["事件:库存已锁定"]
E2 --> P2["策略:发起支付"]
P2 --> C4["命令:创建支付单"]看图的关键:一条命令产生事件,事件触发策略,策略再发出命令——这个闭环是最终一致性的骨架。把它画出来,跨上下文的事务边界就自然浮现了。
2. 以电商为例:划分结果与边界依据#
- 交易上下文:下单、取消、价格计算;
- 履约上下文:仓库、发货、物流;
- 结算上下文:支付、退款、对账;
- 售后上下文:退换货、维修、保修。
每个上下文内部都可以有"用户"“订单"“商品"的概念,但含义不同,模型也不同。注意下表中的同名不同义——这就是边界的直接证据:
| 概念 | 交易上下文中的含义 | 履约上下文中的含义 | 拆分依据 |
|---|---|---|---|
| 订单 | 一份购买契约、金额、优惠 | 一组待出库的拣货任务 | 一个管钱,一个管货;变化频率与团队不同 |
| 商品 | 可售 SKU、价格、库存快照 | 物理货物、库位、体积重量 | 交易关心价格,履约关心物理属性 |
| 用户 | 买家账号、收货信息、等级 | 收件人、联系方式、地址 | 交易需要画像,履约只需要可送达性 |
| 完成 | 支付成功即完成 | 签收才算完成 | 同一流程两种终态,是最典型的边界信号 |
关键原则:领域划分依据业务能力和语言边界,不是数据表,也不是技术分层。
3. 边界粒度:拆分的收益会递减,成本不会#
划完边界,还要回答"多大算合适”。拆分不是免费的:
| 拆分收益 | 拆分成本 |
|---|---|
| 独立部署、独立发版 | 网络调用替代本地调用,延迟与失败率上升 |
| 独立伸缩,按热点分配资源 | 跨服务事务一致性需要最终一致 + 补偿 |
| 团队自治,减少协作阻塞 | 分布式追踪、日志聚合、链路告警的复杂度 |
| 故障隔离,避免一处崩全站 | 契约管理、版本兼容、联调成本 |
| 模型边界清晰,减少相互污染 | 数据冗余与跨库查询能力的丧失 |
经验法则:每拆出一个服务,都要能明确说出它解决了上述哪一个成本问题。说不出,就不拆。服务数量本身不是架构质量的指标。
四、战术设计:边界内如何建模#
进入一个限界上下文后,才用战术设计构建模型。战术设计的目标只有一个:让业务规则有唯一的、可读的、不可绕过的承载点。
1. 实体与值对象:不是分类,是约束#
- 实体:有唯一标识,生命周期内标识不变。例如订单、用户。
- 值对象:没有唯一标识,靠属性值判断相等。例如金额、地址、时间范围。
区别在于相等性语义,以及由此衍生的并发特性:
| 维度 | 实体 | 值对象 |
|---|---|---|
| 相等判定 | 标识相同即相等 | 所有属性相同才相等 |
| 可变性 | 可变(标识不变,状态可变) | 不可变,修改返回新对象 |
| 并发安全 | 需要版本号 / 锁保护 | 天然线程安全,可自由共享 |
| 生命周期 | 有明确的生命周期与状态机 | 无生命周期,随用随弃 |
| 典型例子 | 订单、账户、优惠券 | 金额、地址、时间区间、坐标 |
值对象最大的价值常被低估:它是消灭"原始类型偏执”(Primitive Obsession)的工具。当一个金额、电话、邮箱被建模为值对象时,校验规则、格式约束、运算逻辑就有了唯一的归宿,而不再散落在各个 Service 里各写一遍。
type Money struct {
cents int64 // 用整数分存储,避免浮点误差
currency string
}
// 构造函数是唯一的校验入口,非法状态根本无法被创建
func NewMoney(cents int64, currency string) (Money, error) {
if currency == "" {
return Money{}, ErrCurrencyRequired
}
return Money{cents: cents, currency: currency}, nil
}
// 值对象返回新对象,不修改自身
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrCurrencyMismatch
}
return Money{cents: m.cents + other.cents, currency: m.currency}, nil
}注意 Add 里那道币种校验——如果金额是 int64,这个规则会散落在每一个做加法的 Service 里,漏掉一处就是线上事故。
2. 聚合:一致性边界的本质#
聚合是 DDD 里技术含量最高的概念,也是最容易被简化成"一个主表带几个从表"的概念。
聚合的真正定义:一组必须保持一致性不变量的对象的集合,聚合根是唯一入口。 拆开看,聚合同时是三种边界:
| 聚合承担的边界 | 含义 | 直接后果 |
|---|---|---|
| 不变量边界 | 聚合内所有业务规则在任何时刻都必须成立 | 决定"哪些对象必须放在一起" |
| 事务边界 | 一次事务只修改一个聚合 | 决定"哪些修改可以原子完成" |
| 并发边界 | 锁与版本号的作用范围 | 决定"哪些操作会互相阻塞" |
由此推出聚合设计的四条规则,以及它们为什么成立:
| 规则 | 原因 |
|---|---|
| 小聚合 | 聚合越小,并发冲突概率越低,加载的数据越少。聚合粒度直接等于锁竞争的概率 |
| 通过 ID 引用其他聚合 | 持有对象引用会诱导跨聚合的联动修改,破坏事务边界;同时导致对象图膨胀与懒加载陷阱 |
| 一个事务只修改一个聚合 | 跨聚合的原子性会迫使引入分布式事务,而分布式事务的可用性代价极高 |
| 跨聚合一致性用领域事件与最终一致性 | 把强一致换成有序事件的异步传播,用幂等与补偿换取可用性 |
聚合粒度的取舍,本质上是一道冲突率与加载成本的权衡题:
flowchart TB
subgraph AR["订单聚合(一个事务边界)"]
ROOT["Order(聚合根<br/>持有全部不变量)"]
ROOT --> I1["OrderItem(实体)"]
ROOT --> I2["OrderItem(实体)"]
ROOT --> V1["Address(值对象)"]
ROOT --> V2["Money(值对象)"]
end
ROOT -->|"仅持 ID 引用,不持有对象"| SKU["Product 聚合"]
ROOT -->|"领域事件 OrderPlaced"| EV["事件发布"]
EV -.->|"另一事务"| INV["Inventory 聚合:扣减库存"]
EV -.->|"另一事务"| CRM["CRM 聚合:更新客户画像"]
style AR fill:#f8f9fa反例警示:不要让 Order 直接持有 Product 对象。理由是——商品的价格改了,订单的历史价格就被篡改了;商品对象还会把整个商品表拖进订单事务的加载路径里。正确做法是订单项里保存价格快照 + 商品 ID,这正是"快照式建模"在聚合设计中的体现。
聚合的并发控制通常用乐观锁 + 版本号:
type Order struct {
ID string
version int64 // 乐观锁版本号,由 Repository 统一维护
Status OrderStatus
Items []OrderItem
}
// 状态流转的规则必须封在聚合内,外部无法绕过
func (o *Order) Cancel(reason CancelReason, now time.Time) error {
if o.Status == OrderShipped {
return ErrOrderAlreadyShipped // 已发货不可取消
}
if o.Status == OrderCancelled {
return nil // 幂等:重复取消不报错
}
o.Status = OrderCancelled
o.CancelReason = reason
o.CancelledAt = now
o.AddEvent(OrderCancelled{OrderID: o.ID, Reason: reason})
return nil
}判断聚合粒度是否合理,可以用这三个问题快速自检:
- 不变量:这两块数据之间,是否存在"任何时刻都必须同时成立"的规则?没有,就不该在同一聚合。
- 原子性:是否存在"必须同时成功或同时失败"的修改?没有,就可以拆开。
- 冲突率:合并后,并发写冲突是否显著上升?上升,说明拆得太粗。
3. 领域事件与最终一致性:从"发事件"到"事件不丢"#
领域事件表示领域内已经发生的事实,例如 OrderPlaced、PaymentCompleted。命名一律用过去式——因为它描述的是不可更改的事实,而不是请求。
这里有一个关键区分,很多团队混用了:
| 维度 | 领域事件 | 集成事件 |
|---|---|---|
| 作用范围 | 限界上下文内部,用于解耦聚合 | 跨上下文,用于对外集成 |
| 内容 | 可携带聚合内部结构,字段丰富 | 只携带契约字段,需向后兼容 |
| 契约稳定性 | 可随模型重构而变 | 一旦发布就是对外承诺,只能追加不能删除 |
| 传输方式 | 进程内发布订阅 | 消息队列 / 事件总线 |
直接把领域事件丢进 MQ 是常见错误:本地模型一重构,下游全部炸掉。正确做法是在上下文边界做一次转换——领域事件在内部流转,出边界时翻译成稳定的集成事件。
接下来是真正有技术含量的部分:事件发布的时机陷阱。
朴素写法是在事务提交后发事件,但这中间存在一个致命的窗口:
sequenceDiagram
participant S as 应用服务
participant DB as 数据库
participant MQ as 消息队列
S->>DB: COMMIT(订单已落库)
Note over S,DB: ⚠️ 崩溃窗口:此处宕机则事件永久丢失
S->>MQ: 发布 OrderPlaced反过来,先发消息再提交,则会遇到另一个问题:事务回滚了,消息却已发出,下游看到的是一个不存在的订单。
这两种失败模式无法用"调整顺序"解决,因为数据库事务与消息中间件无法共享同一个原子提交(除非使用支持事务消息的中间件)。工程上成熟的解法是事务性发件箱(Transactional Outbox):
sequenceDiagram
participant App as 应用服务
participant DB as 数据库
participant Relay as 中继(轮询 / CDC)
participant MQ as 消息队列
participant Sub as 订阅方
App->>DB: BEGIN
App->>DB: UPDATE 聚合状态
App->>DB: INSERT outbox 事件记录(同一事务内)
App->>DB: COMMIT
Note over App,DB: 业务状态与事件记录原子写入,要么都成功要么都失败
Relay->>DB: 轮询未投递事件 / 读取 binlog
Relay->>MQ: 至少一次投递
Relay->>DB: 标记已投递
MQ->>Sub: 消费
Note over Sub: 消费方必须幂等(去重表 / 唯一键)这个方案把"分布式原子性"问题降级成了"本地事务 + 至少一次投递",代价是引入幂等消费的要求。这套组合拳是最终一致性的标准骨架,缺一不可:
- 至少一次投递保证不丢,但会重复;
- 幂等消费消化重复,通常用业务唯一键或去重表;
- 补偿事务处理业务上的最终失败,例如库存扣减失败后取消订单并退款;
- 对账兜底作为最后一道防线,处理所有前面都没覆盖到的异常。
一句话原则:分布式系统里没有"不丢不重",只有"不丢重了也能正确处理"。
4. 领域服务、应用服务与策略#
- 领域服务:处理跨实体或跨聚合的领域逻辑,不适合放在单个实体里。例如"根据用户等级、订单金额、活动规则计算最终优惠"。
- 应用服务:编排用例,不包含业务规则。它负责事务、鉴权、日志、事件发布,把真正的决策委托给领域对象。
- 策略 / 规格(Specification):把可组合的规则对象化,用于复杂的校验与筛选条件。
三者的分工可以用一句话记住:应用服务负责"做什么",领域服务负责"怎么按业务规则做",实体负责"我自己的状态我说了算"。
一个常见反面模式是"应用服务膨胀"——事务脚本式的写法把所有规则堆在应用服务里,最终它变成了一个什么都懂、什么都改的上帝类,而领域对象退化成数据传输对象。
5. 仓储、工厂与依赖倒置#
- 仓储:面向聚合根,不是面向表。接口定义在领域层,实现在基础设施层。
- 工厂:处理复杂聚合的创建逻辑,尤其当创建过程本身涉及业务规则时。
- 工作单元(Unit of Work):为"一个事务修改一个聚合"提供落地机制。
仓储的语义是集合,不是 DAO。这个区别很实际:
// 领域层定义:语义是"聚合的集合"
type OrderRepo interface {
NextID(ctx context.Context) (string, error)
Save(ctx context.Context, order *Order) error // 新增与更新统一为 Save
FindByID(ctx context.Context, id string) (*Order, error)
FindByUser(ctx context.Context, userID string, status OrderStatus) ([]*Order, error)
}
// 反例:把仓储写成 DAO,暴露 SQL 式的操作
// type OrderDAO interface {
// UpdateStatus(ctx context.Context, id string, status int) error // ❌ 绕过聚合,破坏不变量
// UpdateAmount(ctx context.Context, id string, amount int64) error // ❌ 业务规则无处安放
// }关键判断:如果仓储接口里出现了 UpdateXxx 这种"改某个字段"的方法,说明聚合的行为被绕过了,聚合内的不变量形同虚设。
至于 FindByUser 这类查询——它返回的是聚合集合,用于业务决策,属于仓储。而报表、列表页这类复杂查询,应该走独立的读模型(CQRS 的 Q 端),不要污染仓储接口:
| 查询类型 | 归属 | 理由 |
|---|---|---|
| 按 ID 加载聚合 | 仓储 | 为写操作服务,需要完整聚合 |
| 按业务条件筛选聚合 | 仓储 | 结果仍用于业务决策 |
| 多表联查、分页列表、报表 | 读模型 / QueryService | 不需要聚合行为,走仓储反而造成过度加载 |
6. 一个完整的对比:贫血模型与充血模型#
同一段"取消订单"的业务,两种写法的差别就是 DDD 是否真正落地的分水岭。
// ❌ 贫血模型:业务规则散落在应用服务里,任何调用方都能绕过
func (s *OrderService) Cancel(ctx context.Context, orderID string) error {
order, err := s.repo.FindByID(ctx, orderID)
if err != nil {
return err
}
if order.Status == 3 { // 魔法数字:3 是什么?调用方还得再判断一次
return errors.New("已发货")
}
order.Status = 4 // 直接赋值,规则可被任意绕过
return s.repo.UpdateStatus(ctx, orderID, 4)
}// ✅ 充血模型:规则封在聚合内,唯一的修改入口
type Order struct {
ID string
Status OrderStatus
Version int64
events []DomainEvent
}
func (o *Order) Cancel(reason CancelReason, now time.Time) error {
if !o.Status.Cancellable() { // 规则由状态自身表达,而非魔法数字
return ErrOrderNotCancellable{Status: o.Status}
}
o.Status = OrderCancelled // 只有聚合自己能改自己的状态
o.CancelReason = reason
o.CancelledAt = now
o.events = append(o.events, OrderCancelled{OrderID: o.ID, Reason: reason})
return nil
}
// 应用服务只做编排:加载 -> 委托 -> 保存 -> 发布事件
func (uc *OrderUsecase) Cancel(ctx context.Context, orderID string, reason CancelReason) error {
order, err := uc.repo.FindByID(ctx, orderID)
if err != nil {
return err
}
if err := order.Cancel(reason, uc.clock.Now()); err != nil {
return err
}
if err := uc.repo.Save(ctx, order); err != nil {
return err
}
return uc.publisher.Publish(ctx, order.Events()...)
}差别不在于"写得更啰嗦",而在于约束的强制性:贫血模型下,任何人新增一个入口都可能忘记校验;充血模型下,引擎层面就无法构造出非法状态。
五、DDD 与微服务:边界如何映射到部署#
限界上下文是微服务拆分的候选边界,但不是强制一一对应。
flowchart TB
BC["限界上下文<br/>(逻辑边界,由语言与模型决定)"]
BC -->|"最常见"| M1["1:1 一个微服务"]
BC -->|"伸缩性/团队驱动"| M2["1:N 拆成多个服务<br/>如读写分离、CQRS"]
BC -->|"慎用"| M3["N:1 一个服务含多个上下文"]
M3 -.->|"后果"| BAD["分布式单体:<br/>共享模型 + 同步调用 + 强一致事务"]- 一个限界上下文通常对应一个微服务;
- 也可以根据团队、性能、部署需求,把一个上下文拆成多个服务;
- 不推荐一个服务包含多个限界上下文,那会变成分布式单体。
识别分布式单体的四个信号(命中两个以上就要警惕):
| 信号 | 表现 | 说明 |
|---|---|---|
| 共享模型 | 多个服务引用同一个 common 包里的实体定义 | 模型没有边界,改动会牵一发动全身 |
| 同步调用链 | 一个请求串行经过 4 个以上服务 | 可用性相乘,链路一坏全坏 |
| 强一致事务 | 跨服务使用分布式事务或"先改 A 再改 B,失败回滚" | 边界与事务边界不一致 |
| 联合发版 | 两个服务必须同时发布才能上线 | 拆了个寂寞,只是把单体拆成了进程 |
微服务拆分的关键不是"拆得多细",而是边界是否与业务语言一致。如果两个服务共享同一套模型、频繁同步调用、需要强一致事务,那它们很可能应该属于同一个限界上下文。
反过来也要注意:不要为了"架构先进"而拆。一个日活几千的内部系统,强行拆成 12 个微服务,得到的不是可扩展性,而是一个需要专职运维的分布式单体。
六、DDD 与 Kratos 四层架构#
Kratos 的四层架构常被理解为:Server / Service / Biz / Data,外加 api 和 cmd。
它可以映射到 DDD 分层:
flowchart TB
S["Server 层<br/>HTTP / gRPC 启动、中间件、生命周期"]
SV["Service 层<br/>应用层:接口适配、DTO 转换、用例编排"]
B["Biz 层<br/>领域层:聚合、实体、值对象、用例、Repository 接口"]
D["Data 层<br/>基础设施:Repository 实现、DB、缓存、外部 RPC"]
S --> SV --> B
B -.->|"依赖倒置:只依赖接口"| D
D -.->|"运行时由 wire 注入实现"| B
style B fill:#f8f9fa| Kratos 层 | DDD 对应层 | 职责 | 不该出现的东西 |
|---|---|---|---|
| Server | 基础设施 / 用户界面 | HTTP、gRPC 启动,中间件,生命周期 | 业务判断 |
| Service | 应用层 | 接口适配,DTO 转换,编排用例 | 业务规则、领域计算 |
| Biz | 领域层 | 领域模型、用例、Repository 接口 | SQL、框架依赖、具体中间件 |
| Data | 基础设施层 | Repository 实现,数据库、缓存、外部 RPC | 业务规则(除了持久化细节) |
Kratos 是一个 DDD-ready 的容器,但它不强制你写出 DDD 代码。
如果 Biz 层里只有一堆过程式 Use Case,操作的是贫血 struct,业务规则散落在 Service 和 SQL 里,那它只是分层架构,不是 DDD。
判断标准:
- 领域对象有行为吗?
- Repository 接口是为聚合根定义的吗?
- 业务规则在领域对象里,还是散落在应用服务里?
三个都是"否",就还没进入 DDD。
一个典型的 Kratos 目录结构:
api/ # proto 契约:对外承诺,需保持兼容
cmd/ # wire 组装,启动
internal/
server/ # HTTP / gRPC 启动、中间件
service/ # 应用服务:接口适配、DTO <-> 领域模型转换
biz/
order.go # 聚合、实体、值对象、领域事件
order_status.go # 状态枚举与状态流转规则
order_usecase.go # 用例编排:事务、事件发布
order_repo.go # Repository 接口(领域层定义)
data/
order_repo.go # Repository 实现(基础设施层)
model/ # ORM 模型,与领域模型分离Repository 接口定义在 Biz,实现在 Data,这就是依赖倒置——编译期领域层不依赖任何基础设施。
这里有一个实践中最容易含糊的点:Biz 里的聚合与 Data 里的 ORM 模型该不该是同一个结构体?
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
复用同一个结构体(常加 biz:"-" 标签) | 聚合直接带 ORM 标签 | 少一层转换,代码量低 | 领域模型被持久化细节污染,重构受限 |
| 模型分离 | biz.Order 与 data.OrderPO 分离 | 领域模型纯净,两边可独立演进 | 需要写转换代码,有维护成本 |
另一个高频问题是:事务该由谁管? 常见做法是应用服务持有一个 Transaction 抽象,在用例层开启事务,把带事务的 ctx 传给仓储实现。这样事务边界是显式的、出现在用例里的,一眼就能看出"这个用例动了几个聚合":
func (uc *OrderUsecase) Cancel(ctx context.Context, orderID string, reason CancelReason) error {
return uc.tx.Transaction(ctx, func(ctx context.Context) error {
order, err := uc.repo.FindByID(ctx, orderID)
if err != nil {
return err
}
if err := order.Cancel(reason, uc.clock.Now()); err != nil {
return err
}
// 同一事务内:保存聚合 + 写入事件(在 repo 内部完成)
// 跨聚合/跨上下文的动作,交给事件异步处理
return uc.repo.Save(ctx, order)
})
}注意函数体里的注释:事务内只有本聚合的写操作。如果这里出现了对第二个聚合的直接调用,就说明事务边界被破坏了。
七、落地路线图:分阶段推进#
DDD 很难一次全面铺开,按下面的节奏推进,风险最低:
| 阶段 | 关键活动 | 产出物 | 参与人 | 周期参考 |
|---|---|---|---|---|
| 一、事件风暴 | 还原业务流程,识别领域事件、命令、策略、读模型 | 事件墙、业务流程闭环图 | 业务 + 产品 + 研发 | 1–2 天工作坊 |
| 二、划分子域 | 区分核心域、支撑域、通用域 | 子域清单 + 投入策略 | 业务负责人 + 架构师 | 1 周 |
| 三、定义限界上下文 | 做语言一致性检查,确定边界 | 上下文清单 + 边界依据说明 | 研发主导 | 1–2 周 |
| 四、建立通用语言 | 术语表双向维护,落到命名规范 | 术语表 + 命名约定 + 评审清单 | 全团队 | 持续 |
| 五、上下文映射 | 确定集成方式与上下游关系 | 上下文地图 + 集成方式决策记录 | 架构师 + 各团队代表 | 1 周 |
| 六、设计聚合 | 识别不变量,小聚合,ID 引用 | 聚合清单 + 领域事件清单 | 研发 | 与开发同步 |
| 七、定义仓储接口 | 面向聚合根,不面向表 | 领域层接口定义 | 研发 | 与开发同步 |
| 八、试点落地 | 从核心域的一个上下文开始,跑通全流程 | 一个完整的 DDD 服务 | 试点团队 | 3–6 周 |
| 九、复盘推广 | 沉淀模式与反模式,逐步推广 | 团队内部规范文档 | 试点团队 | 持续 |
三个执行要点:
- 顺序不能倒:跳过战略设计直接做战术设计,团队会在错误的边界内精益求精。先战略,后战术。
- 必须试点:不要全公司铺开。选一个中等复杂度、业务方配合度高的上下文试点,成功后再复制。
- 允许边界演化:第一次划分几乎不可能完全正确。把边界当作可迭代的假设,在试点中验证——但要记住,边界调整的成本随接入方数量增长,越晚调整越贵。
八、常见误区:症状、根因与修复#
| 误区 | 症状 | 根因 | 修复方向 |
|---|---|---|---|
| 贫血模型 | 实体只有 getter/setter,Service 里全是过程式代码,同一规则在多处重复 | 把领域对象当数据传输对象,规则无处安放 | 把规则搬回聚合,用构造函数守住非法状态,禁止公开 setter |
| 按表拆服务 | 用户表一个服务、订单表一个服务,改一个功能要动三个服务 | 用数据模型替代领域模型,把 ER 图当边界依据 | 回到语言边界与业务能力重新划分,参考"同名不同义"矩阵 |
| 上下文过细 | 每张小功能一个上下文,分布式事务与调用链爆炸 | 混淆了"模块"与"上下文",忽略分布式成本 | 合并变化频率一致、团队一致的上下文,用"能否独立发版"检验 |
| 聚合过大 | 一个聚合几十个实体,并发冲突严重,加载一次上百行 | 按对象关联而非不变量确定聚合边界 | 用"是否存在必须同时成立的不变量"重新切分,改 ID 引用 |
| 通用语言停留在文档 | 术语表写得漂亮,代码里 Order/Trade/Deal 混用 | 语言没有被纳入工程约束 | 术语表双向维护,命名作为 Code Review 必查项 |
| 忽略组织架构 | 边界与团队边界严重冲突,两个团队共写一个上下文 | 忽视康威定律,只做技术设计 | 边界要么对齐团队,要么调整团队;不要指望跨团队共治一个模型 |
| 战术设计过度 | 简单 CRUD 也硬上聚合、领域事件、CQRS,代码量翻三倍 | 把 DDD 当模板套用,而非按复杂度匹配手段 | 简单子域用简单方案,DDD 的投入应与业务复杂度成正比 |
| 上下文映射缺失 | 服务间调用哪里方便哪里连,形成网状依赖 | 只做了划分,没定义关系 | 画出上下文地图,明确每个连接的模式(ACL / OHS / 事件) |
| 领域事件当集成事件用 | 内部模型一重构,下游服务全挂 | 混淆两种事件的契约稳定性要求 | 在上下文边界做一次翻译,对外只暴露稳定的集成事件 |
| 先发消息后落库 | 偶发"下游收到不存在的订单"或"事件丢失" | 数据库与消息队列无法原子提交 | 引入事务性发件箱,配合幂等消费与对账兜底 |
九、总结#
DDD 的本质是降低业务复杂度,不是技术炫技。判断一次 DDD 实践是否成功,不看用了多少模式,而看三个问题:
- 业务方和研发是否在用同一套词汇讨论需求?
- 新的业务规则,是否有一个明确的、唯一的落点?
- 服务边界是否稳定——改动大部分时候只在一个上下文内?
它的核心顺序是:
先战略,后战术;先边界,后模型;先业务语言,后技术实现。
贯穿全文的几条主线:
- 限界上下文是微服务拆分的依据,它同时是语言边界、模型边界和事务边界;
- 聚合是事务一致性的边界,粒度由不变量决定,跨聚合协作只能靠领域事件与最终一致性;
- 领域事件是上下文解耦的手段,但真正让它在生产环境可靠的是发件箱 + 幂等 + 补偿 + 对账这套组合拳;
- 通用语言是团队协作的基础,它必须活在代码里,而不是活在文档里;
- 上下文映射不只是集成方案,它记录了团队之间的权力关系与协作方式。
Kratos 这类框架可以提供 DDD 的物理分层,但真正的 DDD 取决于你是否在 Biz 层认真建模,是否让领域对象承载业务规则,是否用业务语言划分边界。
边界比技术重要,模型比框架重要。 而在这两者之前,还有一个更重要的前提:这件事的业务复杂度,是否真的值得上 DDD。