跳到正文

Blog

Appaloft 里的 TypeScript DDD(三):Value Object

Appaloft 的 TypeScript value-object 风格:schema version、literal union、id、secretRef、masked connection 和 provider 边界 alias。

AppaloftDDDValue ObjectTypeScript

Value object 不一定要写成很复杂的 class。在 Appaloft 的 TypeScript 代码里,很多 value object 就是小的 readonly shape、literal union、schema-version constant 和 normalized reference object。

这听起来不华丽,但正因为不华丽,它们很容易持续使用。

Appaloft 有很多地方不能随便传字符串:resource kind、provider key、tenant / environment reference、component id、route behavior、secret reference、lifecycle status、readback evidence。Value object 让这些字符串带上产品语义。

Dependency kind 不是 provider 口语

Dependency resource 是最直接的例子。Appaloft 的 canonical kind 包括 postgresmongodbredismysqlclickhouseobject-storageopensearch

这个选择会阻止 provider 名字变成领域名。MinIO 不是 dependency kind,它只是 object storage 的一种实现。s3minio 可以在 adapter 边界作为输入 alias,但领域里仍然应该说 object-storage

这件事在 Blueprint、dependency provisioning、readback、UI filter 和未来 provider adapter 之间尤其重要。如果每层都发明一个差不多的字符串,测试可能还能过,但产品语言已经裂开了。

类型会把 canonical vocabulary 变得可见,同时仍然允许 adapter/input 边界接受 alias:

export const cloudDependencyResourceKinds = [
  "postgres",
  "mongodb",
  "redis",
  "mysql",
  "clickhouse",
  "object-storage",
  "opensearch",
] as const;

export type CloudDependencyResourceKind =
  (typeof cloudDependencyResourceKinds)[number];

export interface CloudDependencyProvisioningPlanInput {
  readonly kind: CloudDependencyResourceKind | "s3" | "minio";
  readonly mode: "create" | "reuse";
}

Secret 是 reference,不是 value

另一个重要习惯是 secret handling。Cloud dependency resource readback 带的是 secretRef 和 masked connection,不应该返回 secret value。

这在 TypeScript interface 里只差一个字段,在运维里差很多。secretRef 可以被保存、审计、传给 runtime secret resolver。原始密码会不小心进日志、snapshot、测试 fixture 和 analytics。

Installed application 的 generated secrets 也是类似思路。Plan 可以描述 required secret references 和 generation policy。Runtime materialization 可以生成真实值。Readback 只记录用户或 runtime 需要的 reference,不把 plan 变成 secret transport。

Readback shape 保留了连接所需的信息,但不暴露原始 credential:

export interface CloudDependencyResourceReadback {
  readonly sourceMode: "appaloft-managed" | "imported-external";
  readonly providerKey: string;
  readonly lifecycleStatus: "ready" | "degraded";
  readonly connection: {
    readonly host: string;
    readonly maskedConnection: string;
    readonly secretRef: string;
  };
  readonly bindingReadiness: {
    readonly status: "ready" | "blocked";
    readonly reason?: string;
  };
}

Schema version 也是 value object

Appaloft 很多响应 shape 都带 schemaVersion。这个字段不是装饰,它让 CLI、Web console、测试和以后 migration code 知道自己读到的是哪个 contract。

比如:

  • appaloft.cloud.installed-application.plan/v1
  • appaloft.cloud.installed-application.snapshot/v1
  • appaloft.cloud.dependency-provisioning.plan/v1
  • appaloft.cloud.dependency-provisioning.status/v1

这些常量让版本化行为变得可见。Shape 可以演进,而不用假装新旧 payload 是同一个东西。

Literal union 比模糊 boolean 更诚实

Appaloft 经常用小的 status union,而不是一堆 boolean。Installed application 可以是 installingreadyfailedupgradingrollback-requiredrolled-back。Dependency binding readiness 可以是 readyblocked

这比 boolean 不灵活,正是它的价值。isReady 不能说明应用是失败了、还在安装,还是需要 rollback。Literal union 会逼代码把状态说清楚。

当产品以后新增状态,TypeScript 也更容易帮我们找到旧心智模型太小的地方。

Normalization 把 adapter 留在边界外

Value object 还会防止 provider 实现细节往上泄漏。Docker container handle、provider resource id、host port、public endpoint、bucket name、capability evidence 都可以待在 readback 或 adapter-specific field 里。它们不应该成为 Blueprint 或 install plan 的通用语言。

常见模式很简单:

  • public 或 shared domain object 描述 portable intent
  • Cloud value object 补充 hosted context 和 policy
  • adapter readback 补充 provider evidence
  • raw provider response 留在 adapter 内部

这样核心词汇才足够小,能被人读懂和维护。

代价

小 value object 会显得啰嗦。一个快速脚本可以直接传 { kind: "minio" } 然后继续。更严格的模型会先问:MinIO 是 dependency kind、provider、engine,还是 alias?

这个问题会拖慢第一笔修改,但能少很多后续迁移。

对 Appaloft 来说,value object 不是代码风格偏好,而是让产品词汇不容易被捷径冲烂的一种办法。