跳到正文

Blog

Appaloft 里的 TypeScript DDD(二):Aggregate Root

Appaloft 如何用 TypeScript 把 InstalledApplication 建模为 aggregate root,守住 plan acceptance、readback、failure 和 rollback 的状态转换。

AppaloftDDDAggregate RootTypeScript

Aggregate root 最有价值的地方,是把一个生命周期里的关键规则收回来,不让它们散在五个 service、三个 adapter 和若干测试 fixture 里。

这是 Appaloft TypeScript DDD 系列的第二篇。第一篇先讲了为什么要从边界而不是目录开始

在 Appaloft Cloud 里,CloudInstalledApplicationAggregate 就是为这个问题存在的。Blueprint install plan 可以描述应用 bundle,但它还不是运行中的应用。Provider adapter 可以创建资源,但它不应该决定应用什么时候可以从 installing 变成 ready。Repository 可以保存 snapshot,但持久化不是生命周期规则的主人。

Aggregate 夹在这些压力中间。

Aggregate 负责什么

Installed application aggregate 负责一个 accepted install 的状态:

  • application identity 和来源 Blueprint
  • component plan 与 realized component readback
  • dependency binding plan 与 dependency resource readback
  • generated secret reference 与 initial access credential
  • 当前 lifecycle status
  • execution failure 与 rollback record

重点不只是这些字段放在同一个对象里,而是状态变化必须经过命名的方法。

acceptPlan 创建 installing snapshot。recordReadyReadback 把 components 和 dependencies 标成 realized。recordExecutionFailure 把 aggregate 推向失败状态。recordRollback 只在失败或需要回滚之后记录 rollback。

这些方法名就是领域语言。

Aggregate 的 public surface 故意围绕 transition,而不是字段赋值:

const accepted = CloudInstalledApplicationAggregate.acceptPlan({
  plan,
  applicationId: "app_123",
  acceptedBy: "user_456",
  acceptedAt: now(),
  idempotencyKey,
  acknowledgements: cloudInstalledApplicationRequiredAcknowledgements,
});

if (!accepted.ok) {
  return accepted;
}

为什么不让 execution adapter 直接改状态

Execution adapter 理解 provider。它知道怎么 prepare、execute、record progress、rollback 基础设施效果。但它不应该拥有 Appaloft 的 lifecycle 语义。

如果 adapter 可以直接把应用标成 ready,就很容易跳过 dependency readback,或者记录一个 plan 里根本不存在的 component id。如果 command service 把所有转换检查写在内部,后续新增 background execution、deferred execution 时就会复制规则。如果 repository 接受任意 snapshot,测试夹具会慢慢变成第二套生命周期模型。

Aggregate 让每一层的工作更小:

  • command service 负责编排
  • execution adapter 负责 provider work
  • repository 负责保存 snapshot
  • aggregate 负责验证状态转换

这是 DDD,也只是很普通的 TypeScript 工程卫生。

Snapshot 是接口,不是后门

Appaloft 会持久化 CloudInstalledApplicationSnapshot。Aggregate 可以从 snapshot rehydrate,也可以通过 toSnapshot 输出 snapshot。

但这不等于所有调用方都应该直接改 snapshot。Snapshot 是 persistence 和 readback shape。Aggregate method 才是写入边界。

TypeScript 里这个纪律很容易丢,因为 object spread 太方便了。Appaloft 通过 readonly field、result type、方法级校验和测试,让 snapshot 不至于变成随便改的结构体。

生命周期检查是产品行为

有些检查看起来很显然:

  • ready readback 只能在 installing 或 upgrading 时记录
  • rollback 只能在 failed 或 rollback-required 后记录
  • component readback 必须匹配已知 component id
  • dependency readback 必须匹配已知 requirement id
  • acceptance 缺少 required acknowledgement 就要拒绝

这些规则应该待在 aggregate 里,因为它们不是普通技术异常,而是产品行为。Appaloft 说一个应用 ready,就意味着计划中的 components 和 dependencies 已经被观察到。Appaloft 记录 rollback,就意味着生命周期已经进入允许 rollback 的状态。

这些声明应该很难伪造。

Ready readback 也是一个 transition。Aggregate 会检查 component id 和 dependency requirement id 是否来自 accepted plan,然后才推动 snapshot:

const ready = installing.recordReadyReadback({
  changedAt: now(),
  components: [
    {
      componentId: "web",
      resourceId: "res_web",
      deploymentId: "dep_web",
      endpoints: [{ label: "Web", url: "https://example.app", public: true }],
    },
  ],
  dependencies: [
    {
      requirementId: "postgres",
      dependencyResourceId: "dep_res_postgres",
      secretRef: "appaloft-cloud://secrets/postgres",
      maskedConnection: "postgres://***@db.example/app",
    },
  ],
});

代价

Aggregate root 也可能变大。CloudInstalledApplicationAggregate 已经靠近 execution、secrets、credentials、components、dependencies、readback 和 rollback。这是一个需要持续观察的信号。

答案不是一变大就拆。更好的问题是:哪些 invariant 必须一起保持一致?Initial credential reveal 以后可能需要自己的边界。Dependency resource provisioning 已经有独立 workflow。但 installed application lifecycle 仍然需要一个地方决定应用到底是 installing、ready、failed 还是 rolled back。

这就是 Appaloft 判断 aggregate root 的实际标准:不是“它有没有子对象”,而是“如果两个系统部分能独立改变这个生命周期,会不会危险”。