领域驱动设计入门Domain-Driven Design

代码越写越乱,常常不是因为写得差,而是因为一个模型被迫同时表达好几件不同的事。领域驱动设计给的办法分两步:先把边界和语言划清楚,再决定边界里面的代码怎么写。

适合
写过业务系统后端的程序员
用时
约 45 分钟,共 12 节
学完能做到
在自己的代码库里认出八种常见症状,并知道各自该用哪个概念去治
前两篇
财会基础知识入门、有限状态机入门。没读过也能看懂

青禾书店一共有多少本书?

这个互动示例需要启用 JavaScript。

三个答案都对。出问题的不是数字,是“书”这个词。

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';
  }
}

这段代码能跑,也确实在线上跑着。它有三个问题,后面会一个一个修掉:

  1. 会员林小禾买了两本《活着》和一套《三体》,应付 ¥214.20,total 算出来却是 214.20000000000002。
  2. 订单发货之后,addItem 照样能往里加书。
  3. 发收据的邮件服务一超时,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 AdaptersAlistair Cockburn,2005 年和 DDD 很搭,原文没有提到 DDD。六条边没有特别的含义
        洋葱架构、整洁架构Onion, Clean ArchitectureJeffrey Palermo,2008 年;Robert C. Martin,2012 年同上,都是让依赖指向领域的分层方式
        命令查询职责分离CQRSGreg Young可选。读和写用不同的模型。它本身不要求两个数据库,也不要求消息队列
        事件溯源Event SourcingGreg Young、Martin Fowler 等人的文章可选。把每一次状态变化都存成事件。用了领域事件,不等于在做事件溯源
        微服务MicroservicesJames Lewis 和 Martin Fowler 的文章,2014 年比 DDD 晚十一年。限界上下文可以帮你找服务的边界,但两者不是一回事

        还有一点:这些模式讲的是边界和规则,不是 class 语法。用函数、不可变数据和可辨识联合,一样可以写出聚合。

        11存量系统从哪里动手

        没有人能停下来重写

        下面是一条可以边发版边走的路线。

        1. 先画现状的上下文映射图,包括那块谁都不敢碰的旧系统。只画图、不改代码,也能看清最疼的边界在哪里。
        2. 挑一条最疼的边界。动它之前,先写测试把这一块旧代码现在的行为固定下来,包括那些看起来像 bug 的行为。
        3. 在旧系统旁边开一小块新模型,中间用防腐层翻译,旧的 Product 不直接进入新代码。这个做法叫气泡上下文(Bubble Context)。
        4. 用例一个一个地迁过去,每迁一个,就删掉对应的旧代码。这是绞杀者模式(Strangler Fig)。

        下周一的第一个 PR

        • 把一处 number 类型的金额换成 Money。
        • 把一处 order.status = ... 收进一个用业务语言命名的方法。
        • 把一个各小组吵了很久的词,连同它的两种含义,写进仓库里的一份术语表,并在代码里给它两个名字。

        用过头的信号

        • 按表来划聚合:每张表一个聚合,于是也每张表一个仓储。
        • 每个聚合一个微服务。
        • 什么都发事件,却没有人订阅。
        • 目录叫 domain/,类里只有 getter 和 setter,规则仍然全在 Service 里。Martin Fowler 把这种情况叫贫血领域模型(Anemic Domain Model)。

        什么时候不该用

        规则很少的增删改查,用事务脚本(Transaction Script)或者 Active Record 就好,不必为了整齐给它也配上聚合和仓储。通用子域直接买。临时的原型不需要。DDD 的力气要花在核心域里规则最密的地方。

        12小测验

        十道选择题,点选项立刻看到答案和解释

        1. 小测验需要启用 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 ModelFowler 的定义:对象只有数据,规则全在服务里。中文资料里的“贫血、充血”四分法和它不完全对应
        气泡上下文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)。用函数式的方式做领域建模。