Heximon Logo
Architecture

Domain-Driven Design

Model your domain with the Entity, ValueObject, and AggregateRoot bases — value equality, dirty tracking, optimistic concurrency, and domain events.

You may never need this — see the Architecture overview for the signals that say you've earned it. Reach for it when a rule like "can't ship twice" or "can't ship empty" starts getting checked in more than one controller, because that's the first sign the rule lives in the wrong place.

The failure mode this fixes

An Order has a rule: it can't ship if it's already shipped, and it can't ship with no items. Left as plain rows, that rule gets checked wherever a route touches the order — the POST /:id/ship handler today, a bulk-fulfillment job next month, an admin action after that. Each call site re-derives the check from the same two columns. Miss one, and an order ships twice.

Two symptoms compound it: write-skew, where two requests load the same row, each computes a valid-looking update from what they read, and the second write silently clobbers the first with no error; and anemic rows, where the data is just a bag of fields (status: string, items: unknown[]) that nothing stops a caller from setting directly, skipping the rule entirely.

Domain-driven design's fix is to make the rule a property of the data, not of the caller: the only way to mutate an Order is through a method that enforces the invariant, a version check makes a stale write fail loud instead of silently losing data, and a fact worth reacting to — "this order shipped" — becomes a recorded event instead of something a second query has to re-derive.

Three building blocks get you there. A value object is compared by its contents, not an identity — Money, Email. An entity has identity that persists across changes — you mutate it, and it works out what changed. An aggregate root is the entity at the top of a cluster: the consistency boundary that guards every invariant and records what happened.

All three are plain classes that extend a typed base — equality, dirty tracking, and rehydration come free.

The ddd-aggregate recipe builds exactly this — an Order that can't ship twice or empty — end to end; treat this page as the reference and that one as the walkthrough.

src/orders/money.value.ts
import { ValueObject } from "@heximon/domain";

interface MoneyValue {
  readonly amountCents: number;
  readonly currency: string;
}

export class Money extends ValueObject<MoneyValue> {
  private static readonly currencyPattern = /^[A-Z]{3}$/;

  public static create(amountCents: number, currency: string): Money {
    if (!Number.isInteger(amountCents) || amountCents < 0) {
      throw new RangeError(`Invalid money amount: ${amountCents}`);
    }
    if (!Money.currencyPattern.test(currency)) {
      throw new TypeError(`Invalid currency: ${currency}`);
    }
    return new Money({ amountCents, currency });
  }

  public get amountCents(): number {
    return this.getInternalValue().amountCents;
  }

  public get currency(): string {
    return this.getInternalValue().currency;
  }

  public add(other: Money): Money {
    if (other.currency !== this.currency) {
      throw new TypeError("Currency mismatch");
    }
    return Money.create(this.amountCents + other.amountCents, this.currency);
  }

  public multiply(factor: number): Money {
    return Money.create(Math.round(this.amountCents * factor), this.currency);
  }
}

Money has no id — it is its amount and currency, so two with the same value are equals. The value is frozen on construction, so every operation returns a new Money (read back through getInternalValue()), and the create factory holds the invariant so a Money is always valid.

To skip the manual checks, hand the base a Standard Schema validator: class BirthDate extends ValueObject(schema) {} validates on construction and throws HTTP 400 on bad input — use a synchronous one.

Value objects nest, and equality recurses all the way down — you never write the comparison, and it stays correct as the shape grows. An OrderItem (a branded ProductId, a Money unit price, a quantity) wraps a nested Money the same way, and two OrderItems are equal only when their unit prices are too. See the ddd-aggregate recipe for that full definition.

Model an aggregate root

An AggregateRoot is the entity at the top of a cluster — the consistency boundary for everything inside it. Invariants live in its methods, so an Order is the only thing that may change its own status or items, and can never reach an invalid state from outside. As it changes it records domain events — facts about what happened, buffered for later dispatch.

src/orders/order.entity.ts
import type { Branded } from "@heximon/primitives";
import { AggregateRoot } from "@heximon/domain";
import { OrderCreatedDomainEvent, OrderShippedDomainEvent } from "./domain-events/order.events";
import { CannotShipTwiceError } from "./order-errors";
import type { OrderItem } from "./order-item.value";

export type OrderId = Branded<string, "OrderId">;

export const OrderStatus = { Draft: "Draft", Shipped: "Shipped" } as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];

interface OrderProps extends Record<string, unknown> {
  items: OrderItem[];
  status: OrderStatus;
  shippedAt: Date | undefined;
}

export class Order extends AggregateRoot<OrderProps, OrderId> {
  public static create(id: OrderId, items: OrderItem[]): Order {
    const order = new Order(id, { items: [...items], status: OrderStatus.Draft, shippedAt: undefined });
    order.addDomainEvent(new OrderCreatedDomainEvent(id)); // buffered, flushed later
    return order;
  }

  public ship(): void {
    if (this.props.status === OrderStatus.Shipped) {
      throw new CannotShipTwiceError();
    }
    this.props.status = OrderStatus.Shipped;
    this.props.shippedAt = new Date();
    this.addDomainEvent(new OrderShippedDomainEvent(this.id, this.props.shippedAt));
  }

  public hasShipped(): boolean {
    return this.props.status === OrderStatus.Shipped;
  }

  public get status(): OrderStatus {
    return this.props.status;
  }
}

The aggregate has no dependency on the event busaddDomainEvent(...) just buffers, and a caller drains it after the save. That keeps Order a pure domain object you can unit-test, and leaves when events fire to the application.

OrderId is a branded typeBranded<string, "OrderId"> from @heximon/primitives. It interchanges freely with a plain string, but the compiler rejects passing one where a differently branded id (a ProductId) is expected — a type-level guard with zero runtime cost, so your domain methods can't cross identifiers.

Know what an entity changed

An Entity (and AggregateRoot, which extends it) has identity: two are equals when they share an id. You mutate its props as plain fields, and the entity works out what changed by diffing a snapshot taken when it was loaded or last saved — no proxy, no per-field tracking call to remember.

That diff drives a DirtyState marker, which a repository reads to pick the right write — so partial updates are free, with no dirty flag of your own:

StateMeaningWrite on save()
DetachedNewly created, never persistedINSERT
PersistentIn sync with the storeNo-op
TransientPersisted, then mutatedUPDATE (changed columns only)

That same diff is what makes the write-skew failure mode from the top of this page fail loud instead of silently losing data — see the next section.

Stop write-skew with optimistic concurrency

Every Entity carries a monotonically increasing version, bumped whenever a Persistent entity goes Transient. A repository's save() issues a version-guarded write — UPDATE … WHERE version = <expected> — and treats "zero rows updated" as a conflict, not a silent no-op.

That's what turns the two-workers-load-the-same-row scenario from the top of this page into a loud ConcurrencyError for the loser, instead of a silently clobbered write:

examples/ladder/L06-ddd/test/users.test.ts
// Two independent reads simulate two workers loading the same row at the same version.
const first = await repository.getById(carol.id);
const second = await repository.getById(carol.id);

first.rename("Carol A");
await repository.save(first);

// The second worker's in-memory version is now stale: its version-guarded UPDATE matches zero rows.
second.rename("Carol B");
await expect(repository.save(second)).rejects.toBeInstanceOf(ConcurrencyError);

// The first writer won; the row is at version 2 with its name.
const reloaded = await repository.getById(carol.id);
expect(reloaded?.name).toBe("Carol A");
expect(reloaded?.getVersion()).toBe(2);

The loser gets a ConcurrencyError to retry against fresh state instead of a lost update — no row locks held across the request, just a version check at write time. This is the same ConcurrencyError hierarchy every dialect implements; see Persistence → optimistic concurrency for the underlying contract, and note that CQRS's command bus already retries on it automatically.

Raise domain errors

A DomainError extends the core HeximonError, so throwing one from any handler renders as a well-formed RFC 9457 problem response with the right HTTP status — no error-mapping layer in between. Set it with the static statusCode.

src/orders/order-errors.ts
import { DomainError } from "@heximon/domain";

export class CannotShipTwiceError extends DomainError {
  public static override readonly statusCode = 409;

  public constructor() {
    super("Order has already shipped");
    this.statusCode = CannotShipTwiceError.statusCode;
  }
}

A forbidden transition is a domain fact, not an HTTP concern — so the rule lives on the aggregate, and the framework turns the throw into a 409 at the boundary.

Record and flush domain events

A DomainEvent is a plain class whose constructor parameters carry its payload. The aggregate records it on a state change; nothing dispatches until you flush.

src/orders/domain-events/order.events.ts
import { DomainEvent } from "@heximon/domain";
import type { OrderId } from "../order.entity";

export class OrderCreatedDomainEvent extends DomainEvent {
  public constructor(public readonly orderId: OrderId) {
    super();
  }
}

export class OrderShippedDomainEvent extends DomainEvent {
  public constructor(
    public readonly orderId: OrderId,
    public readonly shippedAt: Date,
  ) {
    super();
  }
}

Inject the DomainEvents facade — a core provider the DDD plugin contributes app-wide. After repository.save(order), flush the buffered events with one explicit call:

src/orders/orders.controller.ts
import type { Controller, Post } from "@heximon/http";
import { DomainEvents } from "@heximon/domain";
import { NotFoundError } from "@heximon/runtime/errors";
import { Order, type OrderId } from "./order.entity";
import { OrderRepository } from "./order.repository";

export class OrdersController implements Controller<"/orders"> {
  public constructor(
    private readonly repository: OrderRepository,
    private readonly domainEvents: DomainEvents,
  ) {}

  public async ship(action: Post<"/:id/ship">): Promise<{ id: string; status: string }> {
    const id = action.request.pathParams.id as OrderId;
    const order = await this.repository.getById(id);
    if (!order) throw new NotFoundError(`Order ${id} not found`);

    order.ship(); // enforces invariants, records OrderShippedDomainEvent
    await this.repository.save(order);
    await this.domainEvents.emitFrom(order); // drain + dispatch, serial, in order

    return { id: order.id, status: order.status };
  }
}

The flush is explicit on purpose: events fire after the save succeeds, in order, inside the same command transaction — so a handler throw rolls the whole write back atomically instead of leaving a recorded-but-unreacted event. (A command whose owning module has no Database in scope receives no transaction wrapper, so the throw simply propagates without a rollback.)

When a module binds more than one Database, the wrap target is ambiguous and you select it — or opt out — with the { database } type option; see CQRS → command transaction wrap.

A DomainEventHandler reacts to one event class, linked by the event you pass to the base, with its constructor declaring its dependencies:

src/shipments/record-shipment.handler.ts
import type { DomainEventHandler } from "@heximon/domain";
import { OrderShippedDomainEvent } from "../orders/domain-events/order.events";
import { ShipmentTracker } from "./shipment-tracker.service";

export class RecordShipmentHandler implements DomainEventHandler<OrderShippedDomainEvent> {
  public constructor(private readonly tracker: ShipmentTracker) {}

  public handle(event: OrderShippedDomainEvent): void {
    this.tracker.record({ orderId: event.orderId, shippedAt: event.shippedAt.toISOString() });
  }
}

The handler consumes only the flat data on the event — never the Order aggregate — so a domain event is a seam between bounded contexts: one side records a fact, the other reacts, neither imports the other.

Map between domain and the outside world

Aggregates aren't your API shape or your table shape, so DDD ships three mapper contracts — each an abstract class a handler injects by contract, wired to whichever subclass extends it:

ContractDirectionMethod
ResponseMapper<T>domain object → response DTOtoResponse
PersistenceMapperdomain ↔ persisted recordtoDomain / toPersistence
EntityPersistenceMapperentity ↔ row, rehydrating through the basetoDomain / toPersistence

For Drizzle-backed repositories you don't write a PersistenceMapper by hand — the entity definition derives it, rehydrating with the right snapshot so dirty tracking starts clean; see Drizzle.

Wire the module

Domain-event handlers go under the domain namespace — never in providers, because the compiler routes them onto the event tier instead of constructing them as plain injectables. Repositories, services, and mappers are ordinary providers; the DomainEvents facade is contributed by the plugin, never listed.

src/shipments/shipping.module.ts
import { Module } from "@heximon/runtime";
import { RecordShipmentHandler } from "./record-shipment.handler";
import { ShipmentTracker } from "./shipment-tracker.service";

export class ShippingModule extends Module({
  providers: [ShipmentTracker],
  domain: { eventHandlers: [RecordShipmentHandler] },
  exports: [ShipmentTracker],
}) {}

Each bounded context is its own module: orders owns the Order aggregate, shipping owns the handler that reacts to OrderShippedDomainEvent, and neither depends on the other's providers — only on the shared event tier. That is the point of modelling contexts as separate modules.

Finally, register the DDD compiler plugin alongside the HTTP plugin in the heximon.config.ts config file. DomainEventsPlugin wires the facade and pulls in the events plugin domain events dispatch through, so you don't list that one yourself.

heximon.config.ts
import { defineHeximonConfig } from "@heximon/build";
import { DomainEventsPlugin } from "@heximon/domain/compiler";
import { HttpPlugin } from "@heximon/http/compiler";

export default defineHeximonConfig({
  plugins: [new HttpPlugin(), new DomainEventsPlugin()],
});

See also

  • Build a DDD Aggregate — the full worked walkthrough of everything on this page: branded ids, value objects, invariants, optimistic concurrency, and a cross-context domain-event handler, built up file by file with a runnable curl sequence.
  • CQRS — drive aggregate writes through a CommandBus and reads through a QueryBus, with concurrency retry and around-chain interceptors.
  • Events — the in-process and cross-context event tiers, including the integration-event channel for reacting across services.
  • Repository Pattern — persist aggregates behind a typed Repository, with changed-column saves and optimistic concurrency.
  • Persistence — the ConcurrencyError hierarchy and how a dialect enforces the version guard underneath save().
  • Controllers — the HTTP surface that turns requests into aggregate operations and flushes their events.
  • L06 — DDD — a runnable User aggregate with an Email value object, a branded UserId, optimistic-concurrency versioning, and a UserRegisteredDomainEvent flushed across to a decoupled onboarding context.
Copyright © 2026