Skip to content

Blog

DDD in Appaloft TypeScript, Part 3: value objects

How Appaloft uses practical TypeScript value objects: schema versions, literal unions, ids, secret references, masked connections, and provider boundary aliases.

AppaloftDDDValue ObjectTypeScript

Value objects do not have to be elaborate classes. In Appaloft TypeScript, many of them are small readonly shapes, literal unions, schema-version constants, and normalized reference objects.

That may sound underwhelming. It is also why they work.

Appaloft has many boundaries where a casual string would be expensive: resource kinds, provider keys, tenant and environment references, component ids, route behavior, secret references, lifecycle status, and readback evidence. A value object gives those strings a product meaning.

A dependency kind is not provider slang

Dependency resources are a good example. Appaloft uses canonical kinds such as postgres, mongodb, redis, mysql, clickhouse, object-storage, and opensearch.

That choice prevents provider names from becoming domain names. MinIO is not the domain kind; it is one possible object-storage implementation. S3-compatible inputs can be accepted at adapter boundaries, but the domain still says object-storage.

This matters when Blueprints, dependency provisioning, readbacks, UI filters, and future provider adapters all need to talk about the same thing. If every layer invents a slightly different string, tests can still pass while the product language fractures.

The type makes the canonical vocabulary visible, while still allowing aliases at the input boundary:

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";
}

Secrets are references, not values

Another important value-object habit is secret handling. A Cloud dependency resource readback carries a secretRef and a masked connection. It should not return the secret value.

The difference is small in a TypeScript interface and large in operations. A secretRef can be stored, audited, and passed to a runtime secret resolver. A raw password leaks into logs, snapshots, test fixtures, and analytics by accident.

The same style appears in generated secrets for installed applications. The plan can describe required secret references and generation policy. Runtime materialization can create values. Readback records the references the user or runtime needs without turning the plan into a secret transport.

The readback shape keeps the connection useful without exposing the raw 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 versions are value objects too

Almost every Appaloft response shape carries a schemaVersion. That field is not decorative. It lets CLI, Web console, tests, and later migration code know which contract they are reading.

For example:

  • appaloft.cloud.installed-application.plan/v1
  • appaloft.cloud.installed-application.snapshot/v1
  • appaloft.cloud.dependency-provisioning.plan/v1
  • appaloft.cloud.dependency-provisioning.status/v1

These constants make versioned behavior visible. A shape can evolve without pretending old and new payloads are the same thing.

Literal unions beat vague booleans

Appaloft often prefers small status unions over a handful of booleans. Installed applications can be installing, ready, failed, upgrading, rollback-required, or rolled-back. Dependency binding readiness can be ready or blocked.

This is less flexible than booleans, and that is the point. A boolean such as isReady does not explain whether the application failed, is still installing, or needs rollback. A literal union forces the code to name the state.

When the product later adds a state, TypeScript can help find the places where the old mental model was too small.

Normalization keeps adapters outside

Value objects also keep provider implementation details from leaking upstream. Docker container handles, provider resource ids, host ports, public endpoints, bucket names, and capability evidence all have a place in readback or adapter-specific fields. They should not become the universal language of a Blueprint or install plan.

The pattern is simple:

  • public or shared domain objects describe portable intent
  • Cloud value objects add hosted context and policy
  • adapter readbacks add provider evidence
  • raw provider responses stay inside adapters

This keeps the core vocabulary small enough to reason about.

The tradeoff

Small value objects can feel verbose. A quick script could pass { kind: "minio" } and move on. A stricter model asks whether MinIO is a dependency kind, a provider, an engine, or an alias. That question slows the first edit and saves later migrations.

For Appaloft, that is the point of value objects. They are not a style preference. They are a way to make product vocabulary resistant to shortcuts.