1两段你一定见过的代码
先看病,再开药
青禾书店是《财会基础知识入门》里 2026 年 1 月开业的那家书店。三年后,它有了网店、十二家门店,还有一个叫“青禾书友会”的会员订阅:¥199 一年,全场九折。和订单有关的逻辑都在一个 2,000 行的 OrderService 里,下面是把它缩到三十几行之后的样子。
改前
// Before: one service owns every rule, and every caller may change every field.
type LegacyOrder = {
id: string;
status: 'PENDING' | 'PAID' | 'SHIPPED';
items: { title: string; price: number; qty: number }[];
isMember: boolean;
charged: boolean;
};
type Downstream = {
reserveStock(order: LegacyOrder): void;
bookRevenue(order: LegacyOrder): void;
sendReceipt(order: LegacyOrder): void;
};
class LegacyOrderService {
readonly #downstream: Downstream;
constructor(downstream: Downstream) { this.#downstream = downstream; }
total(order: LegacyOrder): number {
const sum = order.items.reduce((acc, item) => acc + item.price * item.qty, 0);
return order.isMember ? sum * 0.9 : sum;
}
addItem(order: LegacyOrder, title: string, price: number): void {
order.items.push({ title, price, qty: 1 });
}
onPaymentCallback(order: LegacyOrder): void {
order.charged = true; // the gateway has already taken the money
this.#downstream.reserveStock(order);
this.#downstream.bookRevenue(order);
this.#downstream.sendReceipt(order);
order.status = 'PAID';
}
}
这段代码能跑,也确实在线上跑着。它有三个问题,后面会一个一个修掉:
- 会员林小禾买了两本《活着》和一套《三体》,应付 ¥214.20,
total算出来却是 214.20000000000002。 - 订单发货之后,
addItem照样能往里加书。 - 发收据的邮件服务一超时,
onPaymentCallback就抛出异常:钱已经扣了,订单却还是待支付。
第二段代码是 Product 类。它有 60 个字段,五个小组都在改:编辑组改简介,交易组改售价,供应链组改库存,会员组改专享标记,财务组改成本。谁都不敢删字段,因为不知道别人是不是还在用。
这两段代码的毛病不在太长。Product 的问题是一个模型被迫同时表达五种含义;OrderService 的问题是业务规则没有归属,散落在每一个调用方手里。领域驱动设计(Domain-Driven Design,简称 DDD)处理的就是这类问题。这个名字来自 Eric Evans 2003 年出版的同名书,核心主张是:和懂业务的人一起建立模型,让代码的结构跟着模型走。
DDD 分成两半:
- 战略设计(Strategic design):通用语言、限界上下文、上下文映射、子域。它决定模型的边界画在哪里,团队之间是什么关系。第 2 到 5 节。
- 战术设计(Tactical design):值对象、实体、聚合、仓储、领域事件。它决定一个边界里面的代码怎么写。第 6 到 9 节。
60 个字段的 Product,用任何战术模式都救不了,只有把边界划开才行。所以这一页先讲战略,后讲战术。Evans 后来在演讲里也说过,战术构件被过度强调了;他 2015 年整理的《DDD Reference》把限界上下文放成了第一个模式。
DDD 的成本是业务专家的时间和建模的纪律,适合规则复杂、会长期演化的部分。维护门店营业时间这种增删改查的页面,直接写 CRUD 就好。Evans 自己也说过,功能简单、业务规则很少的项目用不着它。第 11 节会回到这个问题。
2同一个词,几个意思
药方:通用语言(Ubiquitous Language)
运营在群里说:“把《活着》下架。”编辑组把它从网店隐藏了。供应链组停止了补货。门店系统把商品状态读成 0,扫码时提示“商品不存在”,架上剩下的 12 本卖不出去。三个小组都说自己按需求实现了。
他们确实都没有写错代码。出错的是“下架”这个词:它在三个地方是三个意思,却共用一个 status 字段。
通用语言(Ubiquitous Language)是开发者和领域专家共用的一套词汇。领域专家(Domain expert)指最懂这块业务的人。这套词汇出现在对话里、文档里,也出现在类名和方法名里,每个词只有一个精确的含义。Evans 的说法是:语言变了,模型就变了,代码也要跟着改名。
| 改前 | updateProduct(id, { status: 0 }) |
|---|---|
| 改后 | book.delist():编辑组的意思,网店不再展示stockItem.stopReplenishing():供应链组的意思,不再补货,架上的还能卖 |
通用语言有适用范围。很多团队学到这里的第一反应是开会,把“下架”在全公司统一成一个定义。这通常做不到,也不该做:编辑、仓库、门店说的本来就是三件不同的事,硬统一只会造出一个更大的 Product。一个词怎么都统一不了的时候,它在告诉你这里有一条边界。下一节讲这条边界。
怎么把语言挖出来
办法是和业务方一起走一遍真实的流程。事件风暴(EventStorming,由 Alberto Brandolini 提出)是常用的一种:大家在墙上按时间顺序贴便签,每张写一件用过去式描述的事,比如“订单已支付”“图书已下架”。贴的过程中,同一个词的不同用法会自己暴露出来。通常橙色便签表示领域事件,蓝色表示命令,其他颜色各家习惯不同,所以墙上会一直贴着图例。
约不到业务专家的时候,语言还可以从客服工单、运营报表、培训手册和旧代码里的 if 里挖。旧代码里每一个说不清来历的分支,背后往往是一条没人写下来的业务规则。
3六十个字段的 Product
药方:限界上下文(Bounded Context)
限界上下文(Bounded Context,也译作界限上下文)是一个模型和它的语言适用的范围。在范围之内,每个词只有一个意思;跨过边界,同一个词可以是另一个意思,这是允许的。
对青禾来说,同一本实物书在五个上下文里是五个小模型。下面展示了 Product 的 16 个字段。用按钮切换拆分前后;带红色下划线的 4 个字段有争议,点开看它在各处的含义。
这个互动示例需要启用 JavaScript。
拆开之后,各个模型之间只靠 ISBN 互相指认,不再共用一个类。每个模型只有几个字段,由一个小组负责,改它的时候不用问别人。
边界画在哪里
有四个信号提示这里该有一条边界:
- 语言出现分歧。同一个词,两拨人的解释对不上。
- 变化的节奏不同。简介天天改,成本价一个季度改一次。
- 负责的人不同。不同的小组,背后是不同的业务专家。
- 一致性的要求不同。库存数量必须准确,推荐列表晚几分钟更新没有关系。
也有一个否决信号:如果有一条规则要求两边的数据必须在同一个事务里一起改,先不要拆。
三个常见的混淆
- 限界上下文不是微服务。它是模型和语言的边界,不是部署单元。青禾完全可以在一个单体应用里实现五个上下文:一个上下文一个目录,用 lint 规则禁止互相引用内部类型。Evans 说过,“一个微服务就是一个限界上下文”是过度简化。
- 限界上下文不是聚合。上下文是模型的边界,一个上下文里通常有好几个聚合;聚合是事务一致性的边界,第 7 节讲。这是两种不同的尺度。
- 目录按上下文分,不按技术类型分。按
entities/、services/、repositories/分目录,是 Evans 明确批评过的做法:读目录看不出这个系统在做什么生意。按领域概念来组织代码,在他的书里叫模块(Module)。
src/ ├─ catalog/ 目录:Book,上架和下架 ├─ sales/ 销售:Order,优惠券 │ ├─ order/ Order 聚合 │ └─ payment/ 支付网关的防腐层(翻译层,第 4 节) ├─ inventory/ 库存与履约:StockItem,预留 ├─ membership/ 书友会(核心域,第 5 节):Membership,选书 └─ accounting/ 财务对接:把销售发布的消息译成分录
4别人一发版我就挂
药方:上下文映射(Context Mapping)
拆开之后,上下文之间还是要协作。上下文映射图(Context Map)把它们之间的关系画出来。它首先回答一个问题:谁的变化会影响谁。
上游(Upstream)和下游(Downstream)就是这样定义的:上游一改,下游往往要跟着改;下游怎么改,上游基本不受影响。它说的是影响的方向,不是调用的方向,也不是数据流动的方向。销售调用库存的“预留”接口,发起调用的是销售,但接口长什么样由供应链组说了算,所以库存是上游。
青禾书店的上下文映射图
映射图需要启用 JavaScript 才能显示。
U 表示上游,D 表示下游。虚线框是公司外部的系统。
Evans 整理了九种关系模式。其中合作关系和大泥球不在 2003 年的原书里(原书只有七种),是后来才加入的,《DDD Reference》给它们标了星号。这些模式描述两件事:模型之间怎么翻译,团队之间谁迁就谁。
| 模式 | 谁的选择 | 一句话 |
|---|---|---|
| 合作关系Partnership | 双方 | 两个团队要么一起成功,要么一起失败,所以一起排期、一起发版 |
| 共享内核Shared Kernel | 双方 | 两个团队明确约定共用一小块模型,改之前必须商量 |
| 客户-供应商Customer/Supplier | 双方 | 上游把下游的需求排进自己的计划 |
| 遵奉者Conformist | 下游 | 直接沿用上游的模型,不做翻译 |
| 防腐层Anticorruption Layer | 下游 | 在自己这边建一层翻译,不让上游的词汇进入自己的模型 |
| 开放主机服务Open Host Service | 上游 | 提供一套对所有下游开放的接口 |
| 发布语言Published Language | 上游 | 用一种公开、有版本的格式交换数据,常和开放主机服务一起用 |
| 各行其道Separate Ways | 双方 | 判断集成不划算,各做各的 |
| 大泥球Big Ball of Mud | 现状 | 一块边界混乱的旧系统。在它周围画一条线,别让它扩散 |
有两点容易弄错。开放主机服务和发布语言是上游一端的做法,遵奉者和防腐层是下游一端的做法,它们并不冲突:下游完全可以遵奉上游的发布语言。共享内核也不是公共工具库:它是两个团队之间很紧的耦合,Evans 的要求是越小越好。
遵奉还是翻译
下游选哪种做法,要看三件事:你能不能影响上游,上游的模型好不好,你这边的模型有多值得保护。下面三条关系各发生一次上游变更。切换下游的做法,看要改几处。
这个互动示例需要启用 JavaScript。
书友会对接第三方计费平台的防腐层长这样。平台的 non_renewing 表示“不再续订,但已经付过钱的这一期还没到期”。在书友会的语言里,这位读者仍然是有效会员。这个翻译只写在这一个文件里。
// What the billing platform sends: the shape of a typical subscription-billing webhook.
type BillingWebhook = {
event_type: string;
content: { subscription: { customer_id: string; status: string; current_term_end: number } };
};
// What the Membership context wants to hear, in its own words.
type MembershipFact = { memberId: string; active: boolean; expiresOn: string };
const ACTIVE_STATUSES = ['active', 'in_trial', 'non_renewing']; // "non_renewing" still has a paid term running
const INACTIVE_STATUSES = ['paused', 'cancelled'];
const ignoredEventTypes = new Map<string, number>();
function translateBillingWebhook(webhook: BillingWebhook): MembershipFact | null {
if (webhook.event_type !== 'subscription_changed') {
// Not this context's business. Count it anyway, so that ignoring stays visible.
ignoredEventTypes.set(webhook.event_type, (ignoredEventTypes.get(webhook.event_type) ?? 0) + 1);
return null;
}
const { customer_id, status, current_term_end } = webhook.content.subscription;
if (!ACTIVE_STATUSES.includes(status) && !INACTIVE_STATUSES.includes(status)) {
throw new Error(`计费平台发来了不认识的状态:${status}`);
}
return {
memberId: customer_id,
active: ACTIVE_STATUSES.includes(status),
// Read as a UTC calendar day. A real translator must agree on the time zone with the platform.
expiresOn: new Date(current_term_end * 1000).toISOString().slice(0, 10),
};
}
最后一点:上下文映射图要先画现状,再谈改造。没人负责的接口、绕过接口直接读别人数据库的地方、边界模糊的旧系统,都要如实画上去。Evans 的原话是先把现有的地形画出来,改造以后再说。
5在不该用力的地方用力
药方:子域与核心域(Subdomain, Core Domain)
青禾的技术团队花了半年自研支付对账系统。同一时间,书友会才是读者选青禾、不选别家的原因,它的续费规则却还埋在 OrderService 里的一串 if 里。
子域(Subdomain)是业务本身的组成部分:选书、卖书、管库存、收钱、记账。它是从业务里发现的。限界上下文是软件里设计出来的边界。两者最好能对上,现实中经常对不上,旧系统尤其如此。Vaughn Vernon 的书里把这对区分叫作问题空间和解决方案空间。
| 子域的类型 | 判断标准 | 青禾的例子 | 怎么投入 |
|---|---|---|---|
| 核心域Core Domain | 竞争优势所在:顾客因为它才选你 | 书友会、编辑选书 | 最好的人,最仔细的建模 |
| 支撑子域Supporting Subdomain | 生意离不开它,但它不构成差异,市面上又买不到合适的 | 目录、订单、库存与履约 | 自己做,够用就好 |
| 通用子域Generic Subdomain | 各家公司都一样,早就有人解决了 | 支付、记账、短信 | 买现成的,或者接入第三方服务 |
核心域和通用子域是 Evans 书里的模式。分成三类的说法,是 Vernon、Khononov 等后来的作者整理出来的。
有三点提醒:
- 核心域不等于技术上最难的部分。支付对账很难,但它是通用的。难不难和是不是核心,是两回事。
- 通用不等于简单。支付和身份认证都很复杂,只是不值得你自己再做一遍。
- 分类会变。今天的核心域,几年后可能变成人人都有的通用能力。
这个分类回答的是上一节三件事里的最后一件,你这边的模型有多值得保护:下游是核心域,防腐层最值得建;下游不是核心域、对方的模型又够用,遵奉往往更划算。能不能影响上游、上游的模型好不好,还要另外看。它也决定后面几节的力气花在哪里:值对象、聚合这些战术模式,最该用在核心域。本页拿订单来演示它们,只因为订单的状态流转你多半已经熟悉,状态机篇用的也是它;第 7 节的末尾会回到书友会。
6price: number 满天飞
药方:值对象(Value Object)
进入战术设计,从成本最低的一个模式开始。青禾的代码里,金额是 number,有的字段存元,有的存分;ISBN 是 string,到处都在写 replace('-', '')。在 JavaScript 里,19.9 * 3 等于 59.699999999999996。
值对象(Value Object)是用值来定义的对象:没有身份,不可变,创建时自己校验,按值比较是否相等。两个 ¥20.00 可以互换,所以金额是值对象。
改后
class Money {
readonly #cents: number;
private constructor(cents: number) {
if (!Number.isInteger(cents)) throw new Error('金额必须是整数个“分”');
this.#cents = cents;
}
static cents(cents: number): Money { return new Money(cents); }
static yuan(text: string): Money {
if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new Error(`不是合法的金额:${text}`);
const [yuan, fen = ''] = text.split('.');
return new Money(Number(yuan) * 100 + Number(fen.padEnd(2, '0')));
}
get cents(): number { return this.#cents; }
plus(other: Money): Money { return new Money(this.#cents + other.#cents); }
minus(other: Money): Money { return new Money(this.#cents - other.#cents); }
times(quantity: number): Money { return new Money(this.#cents * quantity); }
/** Rounds half up to a whole cent. The rounding rule lives here and nowhere else. */
percent(rate: number): Money { return new Money(Math.round(this.#cents * rate / 100)); }
equals(other: Money): boolean { return this.#cents === other.#cents; }
toString(): string { return `¥${(this.#cents / 100).toFixed(2)}`; }
}
class Isbn {
readonly #digits: string;
private constructor(digits: string) { this.#digits = digits; }
static parse(text: string): Isbn {
const digits = text.replaceAll('-', '');
if (!/^\d{13}$/.test(digits)) throw new Error(`ISBN 应该是 13 位数字:${text}`);
const sum = [...digits].reduce((acc, digit, i) => acc + Number(digit) * (i % 2 === 0 ? 1 : 3), 0);
if (sum % 10 !== 0) throw new Error(`ISBN 的校验位不对:${text}`);
return new Isbn(digits);
}
equals(other: Isbn): boolean { return this.#digits === other.#digits; }
toString(): string { return this.#digits; }
}
好处是规则有了唯一的住处。四舍五入只写在 percent 里,ISBN 的校验位只算在 parse 里,调用方不可能拿到一个不合法的 Isbn。Money.yuan('19.90').times(3) 得到的是 ¥59.70。
用 TypeScript 写值对象的四个坑
- TypeScript 按结构比较类型。只有公开字段的类,会被一个长得一样的对象字面量冒充。上面两个类用了
#私有字段,冒充就过不了编译。 readonly只在编译期有效,运行时拦不住赋值。#私有字段在运行时也有效。- 两个值对象不能用
===比较,要用equals。 - 值对象经过
JSON.stringify再解析回来,类和方法都丢了。上面的Money甚至会变成一个空对象,因为私有字段不参与序列化。跨进程传递时传基本类型,比如整数的分,到了对面再重新构造。
一个东西是值对象还是实体,要看上下文。全新的《活着》,两本可以互换,库存里只需要记数量。二手书不行:每一册的品相不同、定价不同,要一册一册地认。它就是下一节要讲的实体。
7谁都能把订单改成已支付
药方:实体与聚合(Entity, Aggregate)
在旧代码里,order.status = 'PAID' 出现在 17 个地方。客服后台能给已发货的订单加书;一张订单能不能用两张券,取决于请求是从哪个入口进来的。
实体(Entity)是有身份、有生命周期的对象。订单 QH-1042 的金额和状态都会变,但它始终是同一张订单。身份由业务决定:订单号、ISBN、会员号都可以是标识,它不必是数据库的自增主键。
聚合(Aggregate)是一组必须一起保持一致的对象,对外只有一个入口,叫聚合根(Aggregate Root)。外面的代码只能调用聚合根的方法,不能伸手进去改里面的东西。必须始终成立的业务规则叫不变量(Invariant),由聚合根负责守住。订单聚合有四条:
- 状态只能沿着状态机篇的那张转移表变化。
- 一张订单最多用一张优惠券,而且会员价要达到门槛。
- 已支付的订单,实收金额等于应付金额。
- 离开待支付之后,订单的内容不再变:只有待支付的订单能加商品、用优惠券。
改后
class DomainError extends Error {}
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'completed' | 'cancelled' | 'refunding' | 'refunded';
type OrderAction = 'pay' | 'cancel' | 'ship' | 'confirmReceipt' | 'requestRefund' | 'completeRefund';
// The transition table from the state machine tutorial, now owned by the aggregate.
const TRANSITIONS: Record<OrderStatus, Partial<Record<OrderAction, OrderStatus>>> = {
pending: { pay: 'paid', cancel: 'cancelled' },
paid: { ship: 'shipped', requestRefund: 'refunding' },
shipped: { confirmReceipt: 'completed', requestRefund: 'refunding' },
refunding: { completeRefund: 'refunded' },
completed: {}, cancelled: {}, refunded: {},
};
const STATUS_LABEL: Record<OrderStatus, string> = {
pending: '待支付', paid: '已支付', shipped: '已发货', completed: '已完成',
cancelled: '已取消', refunding: '退款中', refunded: '已退款',
};
const ACTION_LABEL: Record<OrderAction, string> = {
pay: '支付', cancel: '取消', ship: '发货', confirmReceipt: '确认收货',
requestRefund: '申请退款', completeRefund: '完成退款',
};
type OrderLine = Readonly<{ isbn: Isbn; title: string; unitPrice: Money; quantity: number }>;
type Coupon = Readonly<{ code: string; threshold: Money; amount: Money }>;
type OrderEvent =
| { type: 'OrderPaid'; orderId: string; amount: Money }
| { type: 'UnmatchedPaymentReceived'; orderId: string; paymentId: string; amount: Money; reason: string }
| { type: 'OrderShipped' | 'OrderCompleted' | 'OrderCancelled' | 'RefundRequested' | 'OrderRefunded'; orderId: string };
class Order {
readonly id: string;
readonly customerId: string; // another aggregate: referenced by identity, never held as an object
readonly #memberRate: number; // percent of the list price this customer pays, fixed when the order is placed
#lines: OrderLine[] = [];
#coupon: Coupon | null = null;
#status: OrderStatus = 'pending';
#payments = new Set<string>();
#events: OrderEvent[] = [];
constructor(id: string, customerId: string, memberRate: number) {
this.id = id;
this.customerId = customerId;
this.#memberRate = memberRate;
}
get status(): OrderStatus { return this.#status; }
get lines(): readonly OrderLine[] { return [...this.#lines]; }
get coupon(): Coupon | null { return this.#coupon; }
subtotal(): Money {
return this.#lines.reduce((sum, line) => sum.plus(line.unitPrice.times(line.quantity)), Money.cents(0));
}
memberPrice(): Money { return this.subtotal().percent(this.#memberRate); }
amountDue(): Money {
return this.#coupon ? this.memberPrice().minus(this.#coupon.amount) : this.memberPrice();
}
addLine(isbn: Isbn, title: string, unitPrice: Money, quantity: number): void {
this.#mustBePending('添加商品');
if (!Number.isInteger(quantity) || quantity < 1) throw new DomainError('数量必须是正整数');
// The title and the price are snapshots taken now; later catalog changes do not touch this order.
this.#lines.push(Object.freeze({ isbn, title, unitPrice, quantity }));
}
applyCoupon(coupon: Coupon): void {
this.#mustBePending('使用优惠券');
if (this.#coupon) throw new DomainError('一张订单只能用一张优惠券');
if (this.memberPrice().cents < coupon.threshold.cents) {
throw new DomainError(`会员价未满 ${coupon.threshold},不能使用这张券`);
}
this.#coupon = Object.freeze({ ...coupon }); // a private copy: the caller cannot change it afterwards
}
// Commands: each one can be refused.
ship(): void { this.#move('ship', { type: 'OrderShipped', orderId: this.id }); }
confirmReceipt(): void { this.#move('confirmReceipt', { type: 'OrderCompleted', orderId: this.id }); }
cancel(): void { this.#move('cancel', { type: 'OrderCancelled', orderId: this.id }); }
requestRefund(): void { this.#move('requestRefund', { type: 'RefundRequested', orderId: this.id }); }
completeRefund(): void { this.#move('completeRefund', { type: 'OrderRefunded', orderId: this.id }); }
/** A fact reported by the payment gateway: the money has already moved, so it is recorded, never refused. */
recordPayment(paymentId: string, amount: Money): void {
if (this.#payments.has(paymentId)) return; // the same callback delivered twice
this.#payments.add(paymentId);
if (this.#status !== 'pending') {
return this.#recordUnmatched(paymentId, amount, `订单已是“${STATUS_LABEL[this.#status]}”`);
}
if (!amount.equals(this.amountDue())) {
return this.#recordUnmatched(paymentId, amount, `应付 ${this.amountDue()},实收 ${amount}`);
}
this.#move('pay', { type: 'OrderPaid', orderId: this.id, amount });
}
/** Hands over the events recorded since the last call. */
pullEvents(): OrderEvent[] {
const events = this.#events;
this.#events = [];
return events;
}
#mustBePending(what: string): void {
if (this.#status !== 'pending') {
throw new DomainError(`订单已是“${STATUS_LABEL[this.#status]}”,不能再${what}`);
}
}
#move(action: OrderAction, event: OrderEvent): void {
const next = TRANSITIONS[this.#status][action];
if (!next) throw new DomainError(`“${STATUS_LABEL[this.#status]}”的订单不能${ACTION_LABEL[action]}`);
this.#status = next;
this.#events.push(event);
}
#recordUnmatched(paymentId: string, amount: Money, reason: string): void {
this.#events.push({ type: 'UnmatchedPaymentReceived', orderId: this.id, paymentId, amount, reason });
}
}
读这段代码时留意四点:
- 聚合根的方法用业务语言命名:
ship、confirmReceipt,没有setStatus。外面没有任何办法直接给状态赋值。 - 状态机篇的转移表搬了进来。那一篇说规则要守在转移函数里,也就是由当前状态和输入算出下一个状态的那个函数;在这里它是私有方法
#move,归聚合根所有,外面的代码只能通过聚合根的方法触发它。 - 订单行里存的是下单那一刻的书名和单价,是快照。
customerId只是一个标识,订单不持有顾客对象。 addLine不产生事件。只有别人需要知道、业务专家也关心的事,才值得记成事件,不是每次改动都要。
命令可以被拒绝,事实不能
状态机篇把“发货”“支付成功”这些输入统称事件。这一页把它们分开,“事件”一词留给聚合做完之后记下的 OrderShipped 这类领域事件。ship 是一个命令:条件不满足,聚合就拒绝,抛出一个用业务语言写的错误。recordPayment 不一样。支付网关回调的时候,钱已经从顾客的账户里划走了。这是一件已经发生的事,模型不能假装它没发生。所以金额对不上,或者订单已经取消的时候,聚合不抛错,而是记下一个 UnmatchedPaymentReceived 事件,也就是“收到了一笔对不上的款项”,交给人工核对或者自动退款。同一个回调来两次,第二次什么都不做。
下面的面板运行的就是上面这段代码。先用“直接改数据”的方式随便点,也就是绕过聚合根,看哪些规则被破坏;再切到“通过聚合根”,把同样的按钮点一遍。订单的小计是 ¥238.00,会员价 ¥214.20,用券之后应付 ¥194.20。面板上每点一次支付回调,都算网关报来的一笔新收款;要看同一笔回调重复到达的情况,切到“通过聚合根”之后点“网关重发上一笔回调”。
对订单 QH-1042 做一件事
这个互动示例需要启用 JavaScript。
订单现在的样子
四条不变量
聚合该多大
Vaughn Vernon 总结过四条经验法则:只把真正需要同时成立的规则放进同一个聚合;聚合要小;聚合之间只用标识互相引用;跨聚合的规则接受最终一致,通常一个事务只改一个聚合。它们是经验法则,Vernon 自己也列了可以破例的情况。
“下单时要不要在同一个事务里扣库存”是最常见的例子。订单和库存项是两个聚合。库存项被所有买同一本书的订单和门店收银同时争抢,把它拉进订单的事务,冲突会成倍增加。默认的做法是先预留,再用事件协作。预留保留多久是业务决定的事:去问店长,得到“30 分钟”,在代码里写成一个有名字的常量。
落到数据库时,一个聚合整体读、整体写:可以是一个文档,也可以是一张主表加几张子表。聚合根上放一个版本号做乐观锁,两个请求同时改同一张订单,只有一个能成功。
核心域里也是同一套做法
书友会的续费规则,原来是 OrderService 里的一串 if。现在它是一个值对象加一个很小的聚合:
const addDays = (day: string, days: number): string =>
new Date(Date.parse(`${day}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
/** A half-open range of days [start, end): active on `start`, no longer active on `end`. */
class MembershipPeriod {
readonly #start: string;
readonly #end: string;
constructor(start: string, end: string) {
if (!(start < end)) throw new Error('会籍的开始日必须早于结束日');
this.#start = start;
this.#end = end;
}
get start(): string { return this.#start; }
get end(): string { return this.#end; }
covers(day: string): boolean { return this.#start <= day && day < this.#end; }
equals(other: MembershipPeriod): boolean { return this.#start === other.#start && this.#end === other.#end; }
}
type MembershipRenewed = { type: 'MembershipRenewed'; memberId: string; expiresOn: string; price: Money };
class Membership {
static readonly TERM_DAYS = 365;
static readonly EARLY_RENEWAL_PRICE = Money.yuan('179');
static readonly FULL_PRICE = Money.yuan('199');
readonly memberId: string;
#period: MembershipPeriod;
constructor(memberId: string, period: MembershipPeriod) {
this.memberId = memberId;
this.#period = period;
}
isActiveOn(day: string): boolean { return this.#period.covers(day); }
/** Renewing before expiry extends from the old end date; renewing after it starts over from today. */
renew(today: string): MembershipRenewed {
const early = this.isActiveOn(today);
const start = early ? this.#period.start : today;
const end = addDays(early ? this.#period.end : today, Membership.TERM_DAYS);
this.#period = new MembershipPeriod(start, end);
const price = early ? Membership.EARLY_RENEWAL_PRICE : Membership.FULL_PRICE;
return { type: 'MembershipRenewed', memberId: this.memberId, expiresOn: end, price };
}
}
到期前续费,从原来的到期日往后顺延 365 天,价格 ¥179;过期后续费,从当天重新起算,价格 ¥199,中间断掉的那一段不补。这几句话业务专家能直接读懂,也能直接指出哪里不对。
8规则、SQL 和 HTTP 缠在一起
药方:仓储、应用服务、领域服务(Repository, Application Service, Domain Service)
规则搬进聚合之后,OrderService 里还剩三样东西:读写订单,交给仓储;把步骤串起来,交给应用服务;调用库存、财务和邮件,下一节交给领域事件,支付网关这类外部系统前面放防腐层。另外还有一类规则不属于任何一个实体或值对象,放进领域服务。
- 仓储(Repository,也译作资源库)
- 它像一个放聚合的集合:按标识取出来,改完放回去。每个聚合根一个仓储,接口用业务语言写,不暴露表结构。它不是“每张表一个 DAO”:订单行没有自己的仓储,因为外面不该绕过订单去改它。
- 应用服务(Application Service)
- 一个用例一个方法,只做编排:取出聚合,调用它的方法,把聚合连同要通知别人的消息一起保存。它不含业务规则。如果你在应用服务里写出了“如果订单已发货就不能……”,这条规则放错了地方。
- 领域服务(Domain Service)
- 有些规则确实是业务规则,却不属于任何一个实体或值对象,比如“两位会员合并账户时,会籍怎么算”。它们放在领域服务里,名字来自通用语言,本身不保存状态。
/** The published language of Sales: plain values plus a version, so Sales can change its internals freely. */
type OrderPaidV1 = { type: 'sales.order-paid'; version: 1; eventId: string; orderId: string; amountCents: number };
type UnmatchedPaymentV1 = {
type: 'sales.unmatched-payment'; version: 1; eventId: string; orderId: string;
paymentId: string; amountCents: number; reason: string;
};
type OutgoingMessage = OrderPaidV1 | UnmatchedPaymentV1;
// One repository per aggregate root. The interface speaks of orders, not of tables.
interface OrderRepository {
find(id: string): Order | undefined;
/** Saves the aggregate and its outgoing messages in one transaction. */
save(order: Order, outgoing: OutgoingMessage[]): void;
}
class InMemoryOrders implements OrderRepository {
readonly #orders = new Map<string, Order>();
readonly outbox: OutgoingMessage[] = [];
find(id: string): Order | undefined { return this.#orders.get(id); }
save(order: Order, outgoing: OutgoingMessage[]): void {
this.#orders.set(order.id, order);
this.outbox.push(...outgoing); // a real implementation writes both in one database transaction
}
}
// The application service: it coordinates, and holds no business rule of its own.
class RecordPaymentHandler {
readonly #orders: OrderRepository;
readonly #newEventId: () => string;
constructor(orders: OrderRepository, newEventId: () => string) {
this.#orders = orders;
this.#newEventId = newEventId;
}
handle(command: { orderId: string; paymentId: string; amountCents: number }): void {
const order = this.#orders.find(command.orderId);
if (!order) throw new DomainError(`找不到订单 ${command.orderId}`);
order.recordPayment(command.paymentId, Money.cents(command.amountCents));
const outgoing: OutgoingMessage[] = [];
for (const event of order.pullEvents()) {
// What other contexts need crosses the boundary, translated into the published language.
if (event.type === 'OrderPaid') {
outgoing.push({
type: 'sales.order-paid', version: 1, eventId: this.#newEventId(),
orderId: event.orderId, amountCents: event.amount.cents,
});
}
if (event.type === 'UnmatchedPaymentReceived') {
outgoing.push({
type: 'sales.unmatched-payment', version: 1, eventId: this.#newEventId(), orderId: event.orderId,
paymentId: event.paymentId, amountCents: event.amount.cents, reason: event.reason,
});
}
}
this.#orders.save(order, outgoing);
}
}
handle 只有二十来行,业务规则一条都不在这里。聚合和它要发出的消息在同一次 save 里提交,下一节解释为什么。对不上的收款也在这一次 save 里交出去,由负责核对和退款的一方处理。
读的那一侧
列表页要显示顾客名字和书名,报表要跨好几张表统计。这些查询不必经过聚合和仓储,直接查数据库,返回一个专门给页面用的结构就可以。聚合是为了守住修改时的规则;只读的查询没有规则要守。
分层
Evans 书里的分层是四层:用户界面、应用、领域、基础设施,每层只依赖它下面的层。后来更常见的做法是把依赖的方向倒过来,让基础设施去实现领域层里定义的接口,上面的 OrderRepository 就是这样。好处是领域层的测试不需要数据库。为了篇幅,它的 save 顺带收下了要发出的消息;严格分层时,领域层的接口只认识 Order,待发的消息由应用层或基础设施另行写入,但仍然和订单在同一个事务里提交。
9一个回调调三个服务
药方:领域事件(Domain Event)
回到开头的 onPaymentCallback。收到支付回调之后,它依次调用库存、财务和邮件,全部成功了才把订单改成已支付。任何一个超时,开头的第 3 个问题就出现了。
领域事件(Domain Event)是业务专家关心的、已经发生的一件事,用过去式命名,发生之后不可更改:订单已支付,会籍已续期。它不是数据库触发器,也不是“某张表的某一行被更新了”。领域事件不在 Evans 2003 年的书里,是后来才加入的;他 2015 年整理的《DDD Reference》给它标了星号,注明是新增的模式。
有了事件,订单聚合只负责把自己的事做对,然后说一声“订单已支付”。库存、财务、邮件各自订阅,各自处理。库存因此也是这条消息的下游:在“预留”接口上它是上游,在这条消息上销售是上游,第 4 节的图为了简洁只画了前一条。财务上下文收到消息之后,用自己的语言把它重说一遍:
// The Accounting context. It imports nothing from Sales and restates the message in its own terms.
type Posting = Readonly<{ account: string; debit?: Money; credit?: Money }>;
class JournalEntry {
readonly memo: string;
readonly postings: readonly Posting[];
constructor(memo: string, postings: Posting[]) {
const total = (side: 'debit' | 'credit') => postings.reduce((sum, p) => sum + (p[side]?.cents ?? 0), 0);
if (total('debit') !== total('credit')) throw new Error('借贷不相等');
this.memo = memo;
this.postings = Object.freeze(postings.map(p => Object.freeze({ ...p }))); // a private, frozen copy
}
}
class AccountingSubscriber {
readonly entries: JournalEntry[] = [];
readonly #handled = new Set<string>();
handle(message: { type: string; version: number; eventId: string; orderId?: string; amountCents?: number }): void {
if (message.type !== 'sales.order-paid') return; // not this context's business
if (message.version !== 1) throw new Error(`不认识的消息版本:${message.version}`);
if (this.#handled.has(message.eventId)) return; // delivered twice, booked once
const amount = Money.cents(message.amountCents ?? NaN);
this.entries.push(new JournalEntry(`订单 ${message.orderId} 收款`, [
{ account: '银行存款', debit: amount },
{ account: '合同负债', credit: amount },
]));
this.#handled.add(message.eventId);
}
}
这个订阅者不引用销售上下文的任何类型。它只认识销售发布语言里的一种带版本号的消息;销售内部怎么改都行,只要这个格式不变。分录用的是会计篇的科目:收到钱的时候书还没交到读者手上,所以贷方是合同负债,不是收入;等订单确认收货,再把它转成主营业务收入。“借贷必相等”是 JournalEntry 的不变量。
实际项目里,每个上下文有自己的 Money,上下文之间只传整数的分。本页为了篇幅,让两边共用了同一个类。另外,对不上的收款也是进了银行的钱,财务同样要为它记账;这里的订阅者为了篇幅只处理 sales.order-paid。
下面的面板分别运行本页的改前代码和改后代码。邮件服务已经预设为“超时”。在两种做法下各点一次“支付成功”,对比订单的状态和财务的分录。然后留在“改后”:把邮件切回“正常”,点“重试没送到的消息”;再把“财务按消息标识去重”关掉,点“把同一条消息再投一次”,看分录从 1 笔变成 2 笔。
三个下游现在的状况
结果
这个互动示例需要启用 JavaScript。
四点要分清
- 领域事件不等于消息队列。它是建模概念。在同一个进程、同一个数据库里,事件完全可以同步处理,甚至放在同一个事务里。如果处理它的时候要改另一个聚合,那就破了第 7 节“通常一个事务只改一个聚合”这条经验法则;值不值得,看业务能不能接受短暂的不一致。必须异步的,是发给其他系统的消息。
- 对内和对外是两种东西。聚合内部记下的
OrderPaid可以随时调整;跨出上下文的sales.order-paid是对别人的承诺,要有版本号。很多资料把后者叫集成事件(Integration Event),不同作者的叫法不完全一样。 - 可靠投递靠两个实现技巧。把状态和待发的消息写进同一个事务,再由后台任务转发,叫事务性发件箱(Transactional Outbox)。转发可能重复,所以订阅者要按消息的标识去重,也就是做到幂等。这两样都不是 DDD 书里的模式,是让事件可靠的常用做法。
- 可以不一致多久,由业务决定。订单已支付和分录入账之间隔了几秒钟,能不能接受,要去问业务专家。
有的流程跨很多步、持续很久,比如“预留库存,30 分钟内没付款就释放”。负责盯着这种流程、到时间发出下一个命令的东西叫流程管理器(Process Manager),很多框架和文章也把它叫 Saga。Saga 这个词原指 1987 年一篇数据库论文里的做法:一串各自提交的事务,中途失败就用补偿操作逐个撤销。两个词的边界,各家说法不一。流程管理器只管协调,业务规则仍然在聚合里。
10DDD 没有要求的那些东西
它们都是可选的,也都出自别人
下面这些名词经常和 DDD 一起出现,很多人以为做 DDD 就要全套配齐。
| 名词 | 出处 | 和 DDD 的关系 |
|---|---|---|
| 六边形架构Hexagonal, Ports and Adapters | Alistair Cockburn,2005 年 | 和 DDD 很搭,原文没有提到 DDD。六条边没有特别的含义 |
| 洋葱架构、整洁架构Onion, Clean Architecture | Jeffrey Palermo,2008 年;Robert C. Martin,2012 年 | 同上,都是让依赖指向领域的分层方式 |
| 命令查询职责分离CQRS | Greg Young | 可选。读和写用不同的模型。它本身不要求两个数据库,也不要求消息队列 |
| 事件溯源Event Sourcing | Greg Young、Martin Fowler 等人的文章 | 可选。把每一次状态变化都存成事件。用了领域事件,不等于在做事件溯源 |
| 微服务Microservices | James Lewis 和 Martin Fowler 的文章,2014 年 | 比 DDD 晚十一年。限界上下文可以帮你找服务的边界,但两者不是一回事 |
还有一点:这些模式讲的是边界和规则,不是 class 语法。用函数、不可变数据和可辨识联合,一样可以写出聚合。
11存量系统从哪里动手
没有人能停下来重写
下面是一条可以边发版边走的路线。
- 先画现状的上下文映射图,包括那块谁都不敢碰的旧系统。只画图、不改代码,也能看清最疼的边界在哪里。
- 挑一条最疼的边界。动它之前,先写测试把这一块旧代码现在的行为固定下来,包括那些看起来像 bug 的行为。
- 在旧系统旁边开一小块新模型,中间用防腐层翻译,旧的
Product不直接进入新代码。这个做法叫气泡上下文(Bubble Context)。 - 用例一个一个地迁过去,每迁一个,就删掉对应的旧代码。这是绞杀者模式(Strangler Fig)。
下周一的第一个 PR
- 把一处
number类型的金额换成Money。 - 把一处
order.status = ...收进一个用业务语言命名的方法。 - 把一个各小组吵了很久的词,连同它的两种含义,写进仓库里的一份术语表,并在代码里给它两个名字。
用过头的信号
- 按表来划聚合:每张表一个聚合,于是也每张表一个仓储。
- 每个聚合一个微服务。
- 什么都发事件,却没有人订阅。
- 目录叫
domain/,类里只有 getter 和 setter,规则仍然全在 Service 里。Martin Fowler 把这种情况叫贫血领域模型(Anemic Domain Model)。
什么时候不该用
规则很少的增删改查,用事务脚本(Transaction Script)或者 Active Record 就好,不必为了整齐给它也配上聚合和仓储。通用子域直接买。临时的原型不需要。DDD 的力气要花在核心域里规则最密的地方。
12小测验
十道选择题,点选项立刻看到答案和解释
- 小测验需要启用 JavaScript 才能作答。
附术语表与延伸阅读
中文译名不统一,括号里是另一种常见的译法
| 中文 | 英文 | 一句话解释 |
|---|---|---|
| 领域 | Domain | 软件要解决的那块业务本身 |
| 战略设计 | Strategic design | 决定模型的边界和团队之间的关系 |
| 战术设计 | Tactical design | 决定一个边界里面的代码怎么写 |
| 领域专家 | Domain expert | 最懂这块业务的人,不一定懂技术 |
| 通用语言(统一语言) | Ubiquitous Language | 一个限界上下文内,业务和开发共用、也写进代码的词汇 |
| 限界上下文(界限上下文) | Bounded Context | 一个模型和它的语言适用的范围 |
| 上下文映射图 | Context Map | 画出各个上下文之间关系的图,先画现状 |
| 上游、下游 | Upstream, Downstream | 上游的变化往往影响下游,反过来基本不会 |
| 合作关系 | Partnership | 两个团队一起成功或一起失败,共同排期 |
| 共享内核 | Shared Kernel | 两个团队约定共用的一小块模型,改动要协商 |
| 客户-供应商 | Customer/Supplier | 上游把下游的需求排进自己的计划 |
| 遵奉者(跟随者) | Conformist | 下游直接沿用上游的模型 |
| 防腐层 | Anticorruption Layer (ACL) | 下游自己建的翻译层,挡住上游的词汇 |
| 开放主机服务 | Open Host Service | 上游提供的、对所有下游开放的接口 |
| 发布语言 | Published Language | 公开的、有版本的数据交换格式 |
| 各行其道 | Separate Ways | 不集成,各做各的 |
| 大泥球 | Big Ball of Mud | 边界混乱的系统 |
| 子域 | Subdomain | 业务本身的一个组成部分 |
| 核心域 | Core Domain | 竞争优势所在的子域 |
| 支撑子域 | Supporting Subdomain | 必须有、要自己做,但不构成差异的子域 |
| 通用子域 | Generic Subdomain | 各家都一样、可以买现成方案的子域 |
| 值对象 | Value Object | 没有身份、不可变、按值比较的对象 |
| 实体 | Entity | 有身份和生命周期的对象 |
| 聚合、聚合根 | Aggregate, Aggregate Root | 一组一起保持一致的对象,以及它唯一的入口 |
| 不变量(不变条件) | Invariant | 必须始终成立的业务规则 |
| 仓储(资源库) | Repository | 按聚合根存取聚合的集合式接口 |
| 工厂 | Factory | 封装复杂聚合的创建过程 |
| 模块 | Module | 按领域概念组织代码的单元,名字来自通用语言 |
| 应用服务 | Application Service | 编排一个用例,不含业务规则 |
| 领域服务 | Domain Service | 不属于任何实体或值对象的业务规则 |
| 领域事件 | Domain Event | 业务专家关心的、已经发生的事 |
| 集成事件 | Integration Event | 跨上下文发布的、有版本的消息 |
| 最终一致性 | Eventual consistency | 允许短时间不一致,过一会儿一定一致 |
| 事务性发件箱 | Transactional Outbox | 把状态和待发的消息写进同一个事务 |
| 流程管理器 | Process Manager | 协调跨多步的长流程,自己不含业务规则。常与 Saga 混用;Saga 原指靠补偿操作撤销的一串本地事务 |
| 命令 | Command | 请求聚合做一件事,可以被拒绝 |
| 幂等 | Idempotence | 同一条消息处理多次,结果和处理一次相同 |
| 事务脚本 | Transaction Script | 一个用例写成一个过程,适合规则很少的增删改查 |
| 命令查询职责分离 | CQRS | 读和写用不同的模型,是可选的做法 |
| 事件溯源 | Event Sourcing | 把每一次状态变化都存成事件,是可选的做法 |
| 事件风暴 | EventStorming | 用便签贴出事件时间线的建模工作坊 |
| 贫血领域模型 | Anemic Domain Model | Fowler 的定义:对象只有数据,规则全在服务里。中文资料里的“贫血、充血”四分法和它不完全对应 |
| 气泡上下文 | Bubble Context | 在旧系统旁边用防腐层隔出来的一小块新模型 |
| 绞杀者模式 | Strangler Fig | 把用例逐个迁到新实现,逐步替换旧系统 |
和前两篇的对应
- 状态机篇的转移表,现在住在
Order聚合根里。 - 会计篇的“借贷必相等”,是
JournalEntry的不变量。收款时贷记合同负债,也来自那一篇。
延伸阅读
- Eric Evans,Domain-Driven Design(2003),中文版《领域驱动设计:软件核心复杂性应对之道》。他 2015 年整理的 Domain-Driven Design Reference 是免费的模式摘要。
- Vaughn Vernon,Implementing Domain-Driven Design(2013),中文版《实现领域驱动设计》;以及篇幅短得多的 Domain-Driven Design Distilled(2016),中文版《领域驱动设计精粹》。
- Vlad Khononov,Learning Domain-Driven Design(2021)。战略设计讲得很清楚。
- Scott Wlaschin,Domain Modeling Made Functional(2018)。用函数式的方式做领域建模。