Skip to content

Blog

DDD in Appaloft TypeScript, Part 2: aggregate roots

How Appaloft models InstalledApplication as an aggregate root in TypeScript, with plan acceptance, readback, failure, and rollback transitions.

AppaloftDDDAggregate RootTypeScript

An aggregate root is useful when the system has a lifecycle that should not be mutated from five different places.

This is the second post in the Appaloft TypeScript DDD series. The first post covered why we start with boundaries instead of folders.

In Appaloft Cloud, CloudInstalledApplicationAggregate exists for that reason. A Blueprint install plan can describe an application bundle, but it is not yet a running application. A provider adapter can create resources, but it should not decide whether an application is allowed to move from installing to ready. A repository can persist snapshots, but persistence is not the owner of lifecycle rules.

The aggregate sits in the middle of those pressures.

What the aggregate owns

The installed application aggregate owns the state around one accepted application install:

  • the application identity and source Blueprint
  • component plans and realized component readbacks
  • dependency binding plans and dependency resource readbacks
  • generated secret references and initial access credentials
  • current lifecycle status
  • execution failure and rollback records

The important part is not that these fields live together in one object. The important part is that state changes go through named methods.

acceptPlan creates the initial installing snapshot. recordReadyReadback marks components and dependencies as realized. recordExecutionFailure moves the aggregate toward failure. recordRollback records a rollback after failure or rollback-required state.

Those method names are the domain language.

The public surface of the aggregate is intentionally about transitions, not field assignment:

const accepted = CloudInstalledApplicationAggregate.acceptPlan({
  plan,
  applicationId: "app_123",
  acceptedBy: "user_456",
  acceptedAt: now(),
  idempotencyKey,
  acknowledgements: cloudInstalledApplicationRequiredAcknowledgements,
});

if (!accepted.ok) {
  return accepted;
}

Why not let the execution adapter update everything?

Execution adapters know providers. They know how to prepare, execute, record progress, and roll back infrastructure effects. They should not be the authority on Appaloft lifecycle semantics.

If an adapter could directly mark an application as ready, it would be too easy to skip dependency readback or record a component id that was never in the plan. If the command service performed all transition checks inline, future execution modes would copy the same rules. If the repository accepted arbitrary snapshots, test fixtures would slowly become a second lifecycle model.

The aggregate gives each collaborator a smaller job:

  • command service orchestrates
  • execution adapter performs provider work
  • repository stores snapshots
  • aggregate validates transitions

That division is plain DDD, but it is also just good TypeScript hygiene.

Snapshots are an interface, not a loophole

Appaloft persists CloudInstalledApplicationSnapshot. The aggregate can rehydrate from a snapshot, and it can return a snapshot through toSnapshot.

That does not mean every caller should mutate the snapshot directly. The snapshot is the persistence and readback shape. The aggregate methods are the write boundary.

This is an easy place to lose discipline in TypeScript because object spread makes mutation feel cheap. Appaloft leans on readonly fields, result types, method-level validation, and tests to keep the snapshot from becoming a free-for-all.

Lifecycle checks are product behavior

Some aggregate checks look obvious:

  • ready readback can only be recorded while installing or upgrading
  • rollback can only be recorded after failure or rollback-required state
  • component readback must match a known component id
  • dependency readback must match a known requirement id
  • missing required acknowledgements reject acceptance

The reason to keep them in the aggregate is that they are not technical exceptions. They are product behavior. If an Appaloft install says it is ready, the product is saying the planned components and dependencies have been observed. If rollback is recorded, the product is saying the lifecycle had already moved into a state where rollback makes sense.

Those claims should be hard to fake.

Ready readback is also a transition. The aggregate checks that the component and dependency ids came from the accepted plan before it moves the snapshot forward:

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

The tradeoff

Aggregate roots can become too large. CloudInstalledApplicationAggregate already sits near execution, secrets, credentials, components, dependencies, readback, and rollback. That is a warning sign to keep watching.

The answer is not to split it whenever it grows. The answer is to ask which invariants must be consistent together. Initial credential reveal may eventually deserve its own boundary. Dependency resource provisioning already has a separate workflow. But the installed application lifecycle still needs one place that decides whether the application is installing, ready, failed, or rolled back.

That is the practical test for an aggregate root in Appaloft: not “is this an entity with children,” but “would it be dangerous if two parts of the system could change this lifecycle independently?”