Skip to content

Blog

DDD in Appaloft TypeScript, Part 4: specifications

Appaloft's lightweight Specification style in TypeScript, from selection specs to readiness gates and drift reports.

AppaloftDDDSpecificationTypeScript

Specification pattern examples often start with a reusable predicate object. Appaloft uses a lighter version most of the time: named specs that describe selection, readiness, compatibility, or policy decisions without performing side effects.

That last phrase matters. A specification can say what should be selected, whether a requirement is satisfied, or which evidence is missing. It should not quietly mutate a deployment, create a provider resource, or repair drift.

Selection specs replace vague query options

Installed application repositories use named selection specs. A single selection can be by application id or idempotency key. A many selection can be by marketplace slug or all.

This is not complicated. It is valuable because it avoids the classic TypeScript query object with ten optional fields and unclear precedence.

When a repository receives { kind: "idempotency-key", idempotencyKey }, the intent is explicit. When it receives { kind: "marketplace-slug", marketplaceSlug }, the caller is not relying on a nullable applicationId plus a separate marketplaceSlug convention.

Small specs make tests easier to read and harder to accidentally broaden.

The repository does not receive a loose bag of optional filters. It receives a named selection:

export type CloudInstalledApplicationSingleSelectionSpec =
  | { readonly kind: "application-id"; readonly applicationId: string }
  | { readonly kind: "idempotency-key"; readonly idempotencyKey: string };

export type CloudInstalledApplicationManySelectionSpec =
  | { readonly kind: "marketplace-slug"; readonly marketplaceSlug: string }
  | { readonly kind: "all" };

Readiness specs name the gate

Dependency provisioning also uses specification-like inputs. A plan can include version requirements, capabilities, readiness requirements, provider keys, and mode. The workflow then derives required acknowledgements and readback checks.

The key point is that unsupported capability or missing provider support is not buried in a provider exception. The system can return an adapter_unsupported error, a blocked production apply gate, or a status/readback shape with clear evidence.

For example, a production external apply gate returns a blocked decision with required evidence such as owner-approved runbook, provider readback evidence, secret-ref evidence, and rollback-delete evidence. That decision does not create a resource. It states the policy.

That policy returns data the caller can render or audit, not a half-created provider resource:

export function evaluateCloudDependencyProductionExternalApplyGate(input: {
  readonly kind: CloudDependencyProvisioningPlanInput["kind"];
  readonly providerKey: string;
}): CloudDependencyProductionExternalApplyGateDecision {
  return {
    schemaVersion: "appaloft.cloud.dependency-provisioning.external-apply-gate/v1",
    status: "blocked_on_owner_approval",
    kind: normalizeKind(input.kind),
    providerKey: input.providerKey,
    createsExternalResources: false,
    requiredEvidence: [
      "owner-approved-provider-runbook",
      "provider-readback-evidence",
      "secret-ref-evidence",
      "rollback-delete-evidence",
    ],
    reason: "paid_or_production_provider_apply_requires_owner_approval",
  };
}

Drift reports are specifications, not commands

The code-as-infra document draws a line that is easy to lose in automation: drift detection can compare observed state to target evidence, but repair must go through an explicit command.

That is a specification mindset. A drift report can describe findings. It can hash values, summarize missing resources, and say which observed state differs from the portable spec. It cannot become a hidden command handler.

This is especially important in a deployment control plane. If projections or reports can mutate infrastructure, operators lose the ability to review plan, acceptance, apply, verify, and rollback as separate moments.

Specifications can be plain data

Appaloft does not require every specification to be a class with isSatisfiedBy. TypeScript discriminated unions and readonly input objects often work better.

A spec is useful when it names an intention:

  • select this installed application by idempotency key
  • list installed applications for this marketplace slug
  • require this dependency capability
  • block this provider apply until evidence exists
  • compare observed runtime state to this portable spec

The implementation can be a function, class, repository method, or workflow step. The DDD part is the named intent and the side-effect boundary.

The failure mode

The failure mode is turning every parameter object into a “specification” and gaining nothing. Appaloft tries to reserve the pattern for places where ambiguity would be expensive: repository selection, readiness gates, provider support, drift detection, and policy checks.

Used that way, specifications are less about abstraction and more about operational honesty. They say what the system knows, what it does not know, and what must happen before anything changes.