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 包括 postgres、mongodb、redis、mysql、clickhouse、object-storage 和 opensearch。
这个选择会阻止 provider 名字变成领域名。MinIO 不是 dependency kind,它只是 object storage 的一种实现。s3 或 minio 可以在 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/v1appaloft.cloud.installed-application.snapshot/v1appaloft.cloud.dependency-provisioning.plan/v1appaloft.cloud.dependency-provisioning.status/v1
这些常量让版本化行为变得可见。Shape 可以演进,而不用假装新旧 payload 是同一个东西。
Literal union 比模糊 boolean 更诚实
Appaloft 经常用小的 status union,而不是一堆 boolean。Installed application 可以是 installing、ready、failed、upgrading、rollback-required 或 rolled-back。Dependency binding readiness 可以是 ready 或 blocked。
这比 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 不是代码风格偏好,而是让产品词汇不容易被捷径冲烂的一种办法。