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 的实际标准:不是“它有没有子对象”,而是“如果两个系统部分能独立改变这个生命周期,会不会危险”。