首页 / 文章 / 用于在NestJS应用中构建领域的六条DDD规则

用于在NestJS应用中构建领域的六条DDD规则

学习六条实用的领域驱动设计规则,用于组织NestJS模块、实体和事件,从而使各功能保持独立且易于维护。

1991 词

在大多数 NestJS 项目运行半年后,一种常见的问题会出现。你添加一个字段来支持某个功能,结果应用中完全无关的某个部分的测试却突然失败了。团队里有人会问业务逻辑到底放在哪里,而真实的答案往往是“分散在各处”。

这并非工程实践粗疏的体现,通常是因为代码是按照技术层次来组织的,而非围绕其代表的业务概念。完整的领域驱动设计是一套复杂的实践体系,大多数团队都无法完全采用。下面介绍的是一种更为精简的方法:六条在 Nest 应用中确实有效的规则,摒弃那些毫无作用的繁琐流程。可以将其视为选择性领域驱动设计

规则 1 — 不要让一个模型支撑整个应用

几乎每个结构复杂的后端系统都以一个“上帝对象”为核心。它通常被命名为 Order,拥有数十个可为空的字段,且代码库中有一半的文件都会导入它。最终,为仓库团队所做的修改往往会悄悄破坏结账功能。

这个所谓的 Order 实际上是将三个不同的功能隐藏在同一个名称之下:

  • 结账模块负责处理价格、折扣以及支付意图。
  • 配送模块负责处理产品编号和收货地址,与折扣无关。
  • 账单模块负责处理金额和发票编号。

当某个类试图同时处理这三项功能时,折扣字段就会与收货地址紧挨着出现。修改其中一个就有可能破坏另外两个功能。

解决办法是为每个领域创建独立的模型,让它们通过消息进行通信,而非使用共享的类。

class Cart {
  lines: CartLine[];
  discount: Money;
  paymentIntentId: string;
}
class FulfillmentOrder {
  orderId: string;
  shipTo: Address;
  picks: Pick[];
}
this.events.emit(new OrderPlaced(order.id, order.shipTo, picks));

注意,FulfillmentOrder仅通过orderId来标识,完全不包含任何价格或折扣数据——结账流程是通过OrderPlaced事件将订单信息传递给履行环节,而非暴露自身的内部类。这意味着即使编译器允许,价格变动也不会泄露到仓库处理逻辑中。每个领域都构成一个封闭的上下文:拥有独立的模型,其中“订单”这一概念有明确的定义。在单体系统中,这可能只是一组拥有自己数据库表的模块;在微服务架构中,则可能是一个完全独立的服务。无论哪种情况,规则都适用——绝不在边界之间共享模型。

一个有用的测试方法是:在一个上下文中的更改绝不应迫使你去修改另一个上下文。如果出现了这种情况,说明你的边界划分有误。

规则2 — 模块是领域,而非层次

假设你的产品经理要求为订单添加包装功能。看看当项目按技术角色而非业务领域来组织时,这会带来多大的成本:

src/
├── controllers/   # order, auth, product, shipment, payment...
├── services/      # order, auth, product, shipment, payment...
├── entities/
└── enums/         # every enum in the whole app

你需要打开controllers/文件夹,依次浏览认证和配送相关代码,才能找到处理订单的控制器。接着在services/entities/enums/中重复同样的操作。要添加的单一功能分散在这四五个文件夹之中。

那种结构是为了回应“显示所有控制器”这样的问题,但实际上几乎没人会这么问。人们真正想问的是“显示与订单相关的所有内容”。因此应首先按领域来组织代码:

modules/orders/
├── controllers/
├── dto/
├── entities/
│   ├── order.entity.ts
│   └── order-status.enum.ts   # the enum sits next to what it uses
├── repositories/
└── orders.module.ts

采用这种结构后,包装礼物的相关代码只涉及一个文件夹。注意顶层并没有通用的enums/目录——枚举类型应与其描述的对象放在一起。唯一需要遵守的规则是:common/目录中只能存放完全不属于任何特定领域的内容,比如分页辅助函数或基础数据访问层类。一旦common/目录开始涉及订单的相关定义,它实际上就变相变成了另一个模块。

规则3 — 模块应依赖接口,而非彼此的服务

想象在同一轮迭代中要发布两个功能。产品页面需要显示“3个未完成订单”,因此catalog会调用并注入OrderService。而收据则需要产品名称,所以orders会注入ProductService。Nest拒绝将这两个模块这样连接起来:

Nest cannot create the CatalogModule instance.
- A circular dependency between modules. Use forwardRef() to avoid it.

forwardRef()包裹注入操作虽然能掩盖错误,但也会永久地将这两个模块绑定在一起。真正的解决方案是依赖自己定义的一个小型接口,而不是直接调用另一个模块中的服务。

catalog需要阻止删除仍存在于未完成订单中的产品——但只有orders拥有这些信息。与其导入orderscatalog只需定义它需要解答的问题即可:

export interface ProductUsageGuard {
  isProductInUse(productId: string): Promise<boolean>;
}

orders模块通过实现该接口并注册自身来提供答案,这样catalog就可以在无需从orders导入任何内容的情况下提出查询:

for (const guard of this.guards) {
  if (await guard.isProductInUse(id)) throw new ProductInUseError(id);
}
Before:  catalog  ⇄  orders           circular — Nest won't boot
After:   catalog  ◄──implements──  orders   one way — catalog owns the interface

现在的依赖关系是单向的,因此不存在循环,也不需要使用forwardRef()。此外,如果日后出现“不能删除与活跃订阅关联的产品”之类的规则,订阅模块可以自行注册相应的保护机制,而catalog完全无需修改。

规则4 — 保持控制器简洁,让实体承担逻辑处理

试想这样一条规则:“已发货的订单无法取消。”这条逻辑应该放在哪里?在许多代码库中,它最终会被放在最初需要它的地方——通常嵌藏在某个服务内部。随后管理控制台也需要进行同样的检查,夜间批处理任务也需要,最终甚至 webhook 处理器也需要。每个地方都以略有不同的方式重新实现这条规则,总有人会忘记第四个副本,结果已发货的订单就开始被退款。

当一个实体仅仅是一组可供其他代码直接修改的公共字段时,就会出现结构单薄的模型——其症状始终如一:业务规则会泄露出来,并被复制到所有处理这些数据的服务中。

相反,应该将规则附加在真正拥有该状态的对象上:

@Entity()
export class Order {
  status: OrderStatus = OrderStatus.DRAFT;
cancel(): void {
  if (this.status === OrderStatus.SHIPPED) {
    throw new Error('Cannot cancel an order that already shipped');
  }
  this.status = OrderStatus.CANCELLED;
}

现在“cancel”仅在一个地方被定义,且没有任何调用方能够绕过该检查——根本不存在替代路径。无需接触数据库即可轻松进行单元测试。服务层仅负责协调各步骤(如order.cancel()、处理退款、持久化数据),而控制器则几乎不再承担任何功能:

@Post(':id/cancel')
cancel(@Param('id') id: string) {
  return this.orders.cancel(id);
}

以下是职责划分的示意图:

HTTP ─► Controller ─► Service ─► Order  (the rules)
                         └─────► Repository ─► DB  (the queries)

实体负责执行规则,数据访问层处理查询,服务层协调各调用顺序,控制器则专门处理HTTP相关事务。

规则5 — 设计数据结构以避免无效状态的存在

规则4已将逻辑集中在实体中。另外两种设计模式可以进一步完善这一结构,每一种都能有效防止特定类型的错误发生。

值对象用于处理带有规则的原始数据类型。订单总额仅仅是一个数字,因此没有任何机制能阻止优惠券将其降至零以下,也无法防止以欧元支付的退款出现在以美元计价的订单上。定义“货币”的规则并不存在于特定位置。解决此问题的方法是为货币创建一个独立的类型,以此来强制执行这些规则:

export class Money {
  private constructor(readonly cents: number, readonly currency: string) {}

  static of(cents: number, currency: string): Money {
    if (cents < 0) throw new Error('Money cannot be negative');
    return new Money(cents, currency);
  }

  add(o: Money): Money {
    if (o.currency !== this.currency) throw new Error('Currency mismatch');
    return Money.of(this.cents + o.cents, this.currency);
  }
}

有了这样的设计,负数总额或货币不匹配的情况就再也无法出现——类型本身就会加以阻止。这便是所谓的值对象:一种小型、不可变的类型,它通过自身的值而非标识符来被识别。每当遇到带有规则且需要反复校验的原始数据类型时,比如货币、电子邮件地址、CIDR范围等,都应使用值对象;而对于纯粹的标识符这类简单内容,则无需使用。

聚合体用于处理涉及多个对象的规则。订单的总金额必须始终与其各明细项之和相等。如果为OrderLine单独创建存储库,迟早会有人在不更新父订单的情况下保存某条明细,从而导致总金额出现错误。解决方法是从一开始就避免这种情况:将Order设为聚合体根对象——即唯一需要加载或保存的对象,也是该模型部分的入口点。不存在OrderLineRepository;明细项只能通过订单本身进行修改:

addLine(sku: string, price: Money, qty: number): void {
  if (this.status !== OrderStatus.DRAFT) throw new Error('Order already placed');
  this.lines.push(new OrderLine(sku, price, qty));
  this.total = this.sumOfLines();
}

由于只有一个入口点,就不可能意外违反这一约束。尽量让聚合体保持较小规模——仅包含同一事务中确实需要更改的内容——并通过ID来引用其他聚合体,而非直接持有对象引用。

规则6 —— 发布事件而非直接调用服务

结账流程起初很简单,但随后place()的功能不断扩展:保存订单、调用物流服务、处理账单、发送邮件。到了这一步,orders模块已经牵涉到应用程序的一半功能,还必须了解每一个后续步骤。如果下个季度要添加积分功能,又得回去修改与结账无关的模块。

应该反过来思考。orders模块先完成自己的工作,然后再通知相关方发生了什么——它根本不知道是否有人正在监听:

this.events.emit(new OrderPlaced(order.id, order.customerId, items));

每个相关的模块都会独立作出反应:

@OnEvent(OrderPlaced.name)
handle(e: OrderPlaced) { return this.shipping.createShipment(e); }

现在要添加忠诚度积分,就需要在忠诚度模块中加入监听器;而orders部分则保持不变。在不同的服务之间,通过消息中间件(如RabbitMQ)也可以采用同样的思路,但需要额外增加一项保障措施:事务型消息队列。你需要在保存订单的同一事务中将事件写入outbox表,之后再由单独的工作进程来发布该事件。如果没有这一步,在保存订单和发布事件之间如果发生故障,事件就会被悄悄丢弃——在生产环境中,这正是可靠系统与问题频出的系统之间的区别。

需要注意一点:事件会掩盖整体流程,因为没有一处能够展示所有事件的完整顺序。应将事件用于跨上下文边界的响应,而非那些属于同一完整任务的步骤。

好处

有选择地应用DDD并非是为了叠加更多架构层,而是将每段逻辑放置在其应有的位置:模型负责规则,模块负责其处理领域,数据存储层负责查询操作,而事件则用于连接不同的上下文。只要遵守这些边界,你的NestJS应用就能更易于理解、修改,并随着时间持续发展。

相关阅读

  • 为何基于反射的 TypeScript 映射会损害 V8 的性能 — 阐述了在基于反射的对象映射过程中,V8 的隐藏类和内联缓存如何性能下降,以及如何在 NestJS API 中通过 JIT 编译的单态函数恢复速度。