Skip to content

Blog

DDD in Appaloft TypeScript, Part 1: start with boundaries, not folders

How Appaloft applies domain-driven design in TypeScript through Blueprint planning, installed applications, dependency resources, and runtime composition.

AppaloftDDDTypeScriptArchitecture

Domain-driven design is easy to turn into folder decoration. A codebase gets a domain directory, a few services, maybe a value-object.ts, and the hard decisions still leak through controllers, database rows, and provider SDK calls.

Appaloft has tried to take a less cosmetic path. The useful question is not whether a file sits under domain/. It is whether the code can say, clearly, which concept owns a decision and which layer is only translating it.

That matters because Appaloft is not one small web app. It is a deployment control plane with CLI, Web console, Blueprints, Cloud overlays, provider adapters, dependency resources, static artifacts, and future Enterprise distribution. The domain language has to survive multiple entrypoints without becoming a private Cloud dialect.

This is the first post in a short series on how we apply DDD in Appaloft and TypeScript. This one is the map. Later posts go deeper on aggregate roots, value objects, specifications, and dependency injection.

The first rule: name the thing before coding it

The Blueprint ADR is a good example. We explicitly decided that Blueprint is a portable, versioned, instantiable application or service topology definition. It is not a deployment, not a project, not a resource, and not a marketplace listing.

That sentence sounds small. In code, it prevents a lot of damage.

If Marketplace listing data, publisher review, entitlement, and Cloud curation were pushed into the Blueprint definition, Community users would inherit private product concerns. If a Blueprint were treated as a deployment, planning could start to create resources before a user accepts the plan. If database rows became the source of truth for the definition, file import/export and local registries would be weaker than the hosted product.

So the boundary is written down first:

  • public Appaloft owns the neutral Blueprint format, loader, registry, and planner
  • Cloud owns official catalog metadata, curation, authz, and hosted product context
  • installed application lifecycle is a separate Cloud boundary derived from a public bundle plan
  • dry-run planning returns createsExternalResources: false

The TypeScript follows the language instead of inventing a new one.

The contract is deliberately boring. A plan says what it is, whether it creates resources, and what command must follow:

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[];
}

Types carry product commitments

Appaloft uses TypeScript interfaces as product contracts, not as loose DTOs. A response such as CloudInstalledApplicationPlan includes a schemaVersion, createsExternalResources: false, an application identity, components, dependencies, relationships, warnings, and an acceptance contract.

That shape is intentionally explicit. It says: this is a plan, not execution. It says the next step is accept-installed-application-plan. It says the user must acknowledge the Blueprint application bundle, dependency bindings, and user-owned configuration before the system treats the plan as accepted.

The same pattern appears in dependency resource provisioning. The flow is not “call provider SDK and see what happened.” It is plan, accept, realize, read back, expose status, then bind. The code carries separate types for:

  • CloudDependencyProvisioningPlan
  • CloudDependencyProvisioningAcceptance
  • CloudDependencyResourceReadback
  • CloudDependencyProvisioningStatus

This is where DDD becomes operational. The type system is not just preventing a string from becoming a number. It is preventing “projection is command” and “planning is apply” from becoming normal.

Aggregates own lifecycle transitions

CloudInstalledApplicationAggregate is the clearest aggregate-root example in the Cloud code. It accepts a plan, records generated secret readback, records initial credentials, records partial and ready readbacks, records execution failure, and records rollback.

Those methods are not just convenience wrappers over a JSON object. They guard lifecycle transitions. Ready readback can only be recorded while installing or upgrading. Rollback can only be recorded after a failed execution or rollback-required state. Component and dependency readback must refer to known component ids and requirement ids.

That gives the rest of the system a boring but valuable rule: if you want to change the installed application lifecycle, go through the aggregate. The repository persists snapshots. The execution adapter talks to providers. The command service orchestrates. The aggregate decides whether a transition is valid.

Specifications keep reads honest

Appaloft also uses small selection specs instead of ad hoc query flags. For installed applications, a single selection can be by application id or idempotency key. A many selection can be by marketplace slug or all.

That is not a grand Specification-pattern framework. It is a practical version of the same idea: put query intent in a named object so repositories and application services do not invent slightly different meanings for “find installed application.”

The same style shows up in domain readbacks and readiness reports. A spec or read model is not allowed to mutate the world. If drift repair is needed, the code-as-infra document says it must create an explicit command rather than mutating from a projection.

Value objects are boring on purpose

Appaloft’s value-object style is often plain TypeScript: branded ids where public packages provide them, literal unions for resource kinds and statuses, schema-version constants, and small normalized shapes for tenant, project, environment, provider, component, route, and secret references.

The boringness is useful. A dependency kind is not any string; it is one of postgres, redis, mysql, clickhouse, object-storage, opensearch, and related aliases only at adapter boundaries. A Cloud dependency resource does not return secret values; it returns a secretRef and a masked connection. A production external apply gate does not quietly continue; it returns a blocked decision with required evidence.

Value objects are where product vocabulary stops being a comment and starts being input validation, output shape, and test fixture material.

Dependency injection is a boundary tool

Cloud behavior is composed through overlays and dependency containers. For example, a Cloud managed deployment overlay contributes ORPC routes and registers runtime dependencies into an Appaloft container. The container exposes tokens such as authz service, entitlement service, usage meter, static artifact publisher, route activation journal, and provider-facing adapters.

That is inversion of control in a very specific sense. Public Appaloft should not import private Cloud packages. Cloud can import public Appaloft extension points and register private ports, adapters, policies, and providers at the composition root.

This keeps commercial and hosted behavior out of the neutral core while still letting the runtime act like one product.

The Cloud side enters at composition time, through extension points rather than imports from public core back into private packages:

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);
        },
      };
    },
  };
}

The tradeoff

This style costs more than a thin CRUD layer. You write more types. You name more intermediate states. You sometimes stop and add a public neutral extension point before Cloud can implement the private behavior it needs.

The payoff is that Appaloft can keep several difficult promises at the same time:

  • a plan can be inspected before it creates resources
  • a domain event is not automatically a billing event
  • a read model does not become a command handler
  • a provider adapter can be swapped without changing the product language
  • Cloud can add hosted behavior without teaching public Appaloft private business concepts

That is the version of DDD we care about in Appaloft. Not a ritual folder layout. A discipline for keeping product language, TypeScript contracts, and runtime composition pointed at the same facts.