TypeScript 中的有意义领域模型
I’m happy to translate the article for you, but I need the full text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source link and all formatting exactly as you requested.
我在生产环境中看到的大多数 bug 并不是由于错误的算法或糟糕的基础设施导致的。
它们是由 无效状态 引起的——比如没有商品的订单、没有发票 ID 的已付款订单、支付金额超出预期的付款。
代码在语法上是正确的,TypeScript 也没有报错。但业务规则被悄悄地违背了。
本文探讨了几种模式,帮助你构建 域模型,使得无效状态 不可能 出现——并且在出现问题时,使失败 显式。
隐式规则的问题
考虑一个典型的订单实体:
class Order {
status: "DRAFT" | "PLACED" | "PAID";
items: OrderItem[];
invoiceId?: string;
}没有任何东西阻止你创建一个没有 invoiceId 的 PAID 订单。
没有任何东西阻止你删除最后一个商品。规则只存在于你的脑海中(或某处的注释里),而不在代码中。
你可能在某个地方——用例、控制器——添加验证,但没有任何东西能阻止其他开发者(或未来的你)从不同的路径修改状态,从而完全绕过它。
不变量:始终为真的规则
不变量是一条业务规则,必须始终成立,而不仅仅在特定操作之后。
与其在某个地方进行验证并期望没有其他代码修改状态,你只需声明一次规则,它将在每次读取状态时进行检查:
// This rule is always enforced — no matter how state was mutated
const paidOrderHasInvoiceId = new BaseDomainInvariant(
"Paid Order Has Invoice Id",
(state) => {
if (state.status === "PAID") {
return state.invoiceId !== undefined;
}
return true;
}
);实体在构造函数中注册其不变量:
class Order extends DomainEntity {
private constructor(id: string, state: OrderState) {
super(id, state);
this.addInvariant(orderHasAtLeastOneItem);
this.addInvariant(paidOrderHasInvoiceId);
}
}每当你调用 order.readState() 时,都会检查不变量。如果有任何不变量被违反,它会抛出 "Corrupted state detected"。你永远不会悄悄读取到一个已损坏的实体。
不变量也是可组合的:
const complexRule = invariantA.and(invariantB).or(invariantC);使用 Result 模式显式处理失败
TypeScript 允许你在任何地方 throw 任意内容。但 throw 在函数签名中是不可见的——调用者根本不知道会出现什么错误,除非他们阅读实现代码(或在生产环境中发现)。
Result 模式 将失败作为一等返回值:
lockCredits(params: { amount: number }): Result {
if (this.state.subCreditBalance {
constructor(entityId: string, payload: { invoiceId: string }) {
super({ name: "ORDER_PAID", version: 1, entityId, payload });
}
}操作返回它们的事件:
pay(params: { invoiceId: string }): Result {
if (this.state.status !== "PLACED") {
return err(new InvalidStatusTransition(/* … */));
}
this.state.status = "PAID";
this.state.invoiceId = params.invoiceId;
return ok(new OrderPaid(this.id(), { invoiceId: params.invoiceId }));
}你可以原子地持久化实体状态和其事件:
const result = order.pay({ invoiceId: "INV-123" });
if (result.isOk()) {
await repository.saveWithEvents(order, result.value);
}这让你无需额外工作就拥有完整的审计轨迹。事件同样非常适合以解耦的方式触发副作用(邮件、Webhook、投影等)。
综合示例:一个用例
下面是一个完整的 payOrder 用例,组合了所有这些模式:
export async function payOrderUseCase(
repository: OrderRepository,
id: string,
invoiceId: string,
): Promise> {
const getResult = await repository.getById(id);
if (getResult.isErr()) throw getResult.error; // infra error, not domain
const order = getResult.value;
if (order === undefined) {
return err(new EntityNotFound("Order not found", { entityId: id }));
}
const payResult = order.pay({ invoiceId });
if (payResult.isErr()) return err(payResult.error);
const saveResult = await repository.saveWithEvents(order, payResult.value);
return saveResult;
}通过结合 不变式、Result 模式 和 领域事件,你可以获得:
- 不可能出现的非法状态 – 不变式在每次读取时都对实体进行保护。
- 显式的失败处理 – 调用方必须处理
Result的结果。 - 可审计、不可变的历史 – 每一次变更都被记录为一个事件。
if (saveResult.isErr()) throw saveResult.error;
return ok(order.readState());失败处理
注意每种失败的处理方式不同:
- 基础设施错误(例如,DB down):
throw— 这些属于异常情况,而非领域逻辑。 - 领域失败(例如,wrong status):返回一个类型化的
Result— 调用方负责处理。
库
所有这些模式都可以在 Ontologic 中使用——这是一个提供以下功能的小型 TypeScript 工具包:
DomainEntityBaseDomainInvariantDomainEventDomainErrorResultRepository接口
npm install ontologic它并不试图成为一个框架——没有装饰器、没有魔法、也不需要 IoC 容器。只是一组普通的 TypeScript 类和类型,您可以直接在任何项目中使用。
完整文档和示例(包括上面展示的 Order 与 CreditBalance 聚合)请访问 ontologic.site。
这些模式并不新鲜——它们来源于领域驱动设计(Domain‑Driven Design)和函数式编程。但在 TypeScript 代码库中却使用不足,开发者往往倾向于使用 throw 或可选字段来处理错误。
其价值在于:代码读起来像业务规则,出现问题时会显著报错,并且能够防止实体进入不应该出现的状态。