跳到正文

Blog

Appaloft 里的 TypeScript DDD(一):先划边界,再谈目录

Appaloft 如何把 DDD 落到 TypeScript:Blueprint plan、InstalledApplication、DependencyResource、runtime composition,以及 public core 和 Cloud overlay 的边界。

AppaloftDDDTypeScript架构

DDD 很容易被写成目录装饰。代码里有了 domainservicesvalue-object.ts,但真正难的决定仍然散落在 controller、数据库行、provider SDK 调用和临时脚本里。

Appaloft 更关心另一件事:一个概念到底由谁负责做决定,谁只是翻译、持久化、投影或执行。

这件事对 Appaloft 很重要。它不是一个单入口 Web app,而是一个 deployment control plane:CLI、Web console、Blueprint、Cloud overlay、provider adapter、dependency resource、static artifact、Enterprise distribution 都会碰到同一套业务语言。如果边界不稳,最后很容易长出一套 public Appaloft、一套 Cloud Appaloft,再加一堆临时口径。

这篇是一个系列的第一篇,先讲总图。后面几篇会分别写 aggregate root、value object、specification,以及 dependency injection / inversion of control 在 Appaloft TypeScript 代码里的用法。

先把词说准

Blueprint ADR 是一个很好的例子。我们先明确:Blueprint 是 portable、versioned、instantiable 的应用或服务拓扑定义。它不是 Deployment,不是 Project,不是 Resource,也不是 Marketplace Listing。

这句话看起来像文档洁癖,但它会直接改变代码。

如果把 Marketplace listing、publisher review、entitlement、Cloud curation 塞进 Blueprint definition,Community 用户就会被迫继承私有产品语义。如果把 Blueprint 当成 Deployment,dry-run plan 很容易偷偷开始创建资源。如果数据库变成 Blueprint definition 的 source of truth,本地文件 registry、import/export 和离线使用都会变弱。

所以边界先写下来:

  • public Appaloft 负责中性的 Blueprint format、loader、registry 和 planner
  • Cloud 负责 official catalog metadata、curation、authz 和 hosted product context
  • InstalledApplication lifecycle 是 Cloud 的另一个边界,由 public bundle plan 派生
  • dry-run planning 必须返回 createsExternalResources: false

TypeScript 代码跟着这套语言走,而不是重新发明一套内部黑话。

Contract 本身故意写得很朴素。Plan 会说清楚自己是什么、会不会创建资源,以及下一步必须走哪个 command:

export interface CloudInstalledApplicationPlan {
  readonly schemaVersion: "appaloft.cloud.installed-application.plan/v1";
  readonly createsExternalResources: false;
  readonly application: CloudInstalledApplicationIdentityPlan;
  readonly components: readonly CloudInstalledApplicationComponentPlan[];
  readonly dependencies: readonly CloudInstalledApplicationDependencyBindingPlan[];
  readonly acceptance: {
    readonly requiredCommand: "accept-installed-application-plan";
    readonly requiredAcknowledgements: readonly string[];
  };
  readonly warnings: readonly string[];
}

类型本身就是产品承诺

Appaloft 里的 TypeScript interface 不只是 DTO。比如 CloudInstalledApplicationPlan 会明确包含 schemaVersioncreatesExternalResources: false、application identity、components、dependencies、relationships、warnings,以及 acceptance contract。

这个 shape 在表达一个产品承诺:这是 plan,不是 execution。下一步必须是 accept-installed-application-plan。用户要先确认 Blueprint application bundle、dependency bindings 和 user-owned configuration,系统才会把它当作 accepted plan。

Dependency resource provisioning 也一样。它不是“调用 provider SDK,回来看看结果”。它被拆成 plan、accept、realize、read back、expose status,然后 bind。代码里有独立的类型:

  • CloudDependencyProvisioningPlan
  • CloudDependencyProvisioningAcceptance
  • CloudDependencyResourceReadback
  • CloudDependencyProvisioningStatus

DDD 在这里变得很具体。类型系统不只是防止把 string 当成 number,它也在防止“projection 变成 command”、“planning 变成 apply”这些更危险的滑坡。

Aggregate 负责生命周期变化

Cloud 代码里最清楚的 aggregate root 例子是 CloudInstalledApplicationAggregate。它负责 accept plan、记录 generated secret readback、记录 initial credentials、记录 partial / ready readback、记录 execution failure,以及记录 rollback。

这些方法不是给 JSON 对象套一层好看的 API。它们会守住生命周期状态。Ready readback 只能在 installing 或 upgrading 时记录。Rollback 只能在 failed 或 rollback-required 之后发生。Component 和 dependency readback 必须指向已知的 component id 与 requirement id。

这样系统其他部分就有了一个简单规则:要改变 installed application lifecycle,就经过 aggregate。Repository 持久化 snapshot。Execution adapter 和 provider 打交道。Command service 做编排。Aggregate 判断这个状态转换能不能发生。

Specification 让读取意图变得明确

Appaloft 没有把 Specification pattern 做成一个很重的框架,但它会用小的 selection spec 来表达查询意图。Installed application 的 single selection 可以是 application id,也可以是 idempotency key;many selection 可以是 marketplace slug,也可以是 all。

这比到处传一堆可选参数要老实。Repository 和 application service 不会各自解释“查 installed application”到底是什么意思。

同样的原则也出现在 readback、readiness report 和 drift detection 里。Spec 和 projection 只描述、读取、比较事实;它们不能顺手修系统。docs/cloud/code-as-infra.md 里也明确写了:drift repair 必须创建 explicit Command,不能从 projection 里直接 mutate。

Value Object 故意很朴素

Appaloft 的 value object 风格通常很朴素:public package 里的 branded id、资源类型和状态的 literal union、schema-version constant,以及 tenant、project、environment、provider、component、route、secret reference 这些小的 normalized shape。

朴素是优点。Dependency kind 不是任意字符串,而是 postgresredismysqlclickhouseobject-storageopensearch 等明确集合;s3minio 这类名字只应该在 adapter 边界作为 alias 出现。Cloud dependency resource 不返回 secret value,只返回 secretRef 和 masked connection。Production external apply gate 不能悄悄放行,它会返回 blocked decision 和 required evidence。

Value object 让产品词汇不再只是注释,而是输入校验、输出 shape 和测试 fixture 的一部分。

DI/IoC 是边界工具

Cloud 行为通过 overlay 和 dependency container 注入。比如 Cloud managed deployment overlay 会贡献 ORPC routes,并把 runtime dependencies 注册进 Appaloft container。Cloud composition tokens 里有 authz service、entitlement service、usage meter、static artifact publisher、route activation journal,以及 provider-facing adapters。

这就是很具体的 inversion of control。public Appaloft 不应该 import 私有 Cloud package;Cloud 可以 import public Appaloft 的 extension point,并在 composition root 注册私有的 ports、adapters、policies 和 providers。

结果是:runtime 看起来仍然是一个产品,但 public core 不需要知道 Cloud 的商业、托管或企业分发细节。

Cloud 侧是在 composition 阶段进入 runtime 的。它通过 extension point 进入,而不是让 public core 反向 import 私有 package:

export function createCloudManagedDeploymentOverlay(): CloudAppaloftOverlay {
  return {
    name: "cloud-managed-deployment-overlay",
    createServerExtension({ cloud }) {
      return {
        name: "cloud-managed-deployment",
        http: {
          orpcRouterContributions: [createCloudManagedDeploymentOrpcRouterContribution()],
        },
        configureApplication(context) {
          registerCloudManagedDeploymentRuntimeDependencies(context.container, cloud);
        },
      };
    },
  };
}

代价和收益

这种写法比薄 CRUD 层更费事。你会写更多类型,命名更多中间状态;有时 Cloud 想实现一个私有行为,还得先停下来补 public neutral extension point。

但它换来的东西也很实际:

  • plan 可以先被 review,再创建资源
  • domain event 不会自动变成 billing event
  • read model 不会顺手变成 command handler
  • provider adapter 可以替换,而不用改产品语言
  • Cloud 可以加 hosted behavior,但不把私有商业概念塞进 public Appaloft

这就是 Appaloft 里我们关心的 DDD。不是仪式感目录,而是一套让产品语言、TypeScript contract 和 runtime composition 对齐的纪律。