Heximon Logo
Recipes

Build a DDD Aggregate

Model an Order aggregate root with branded ids, value objects, invariants, DomainEvent recording, DomainEvents.emitFrom flushing, and a cross-context DomainEventHandler.

You have a business rule that has to hold no matter who calls it — an order can't ship empty, and can't ship twice. Scatter that check across controllers and you'll eventually miss a path. A domain-driven aggregate makes the rule a property of the data: the only way to mutate an Order is through methods that enforce the invariant, so an invalid order can't exist.

This recipe builds that Order end to end — branded ids, value objects, invariants, recorded domain events — then reacts to the shipment from a separate context that never imports the orders module. You end with a POST /orders/:id/ship that fans an OrderShippedDomainEvent out to a shipping read model, coupled only through the event shape.

Brand the identifiers

A Branded<string, "OrderId"> is a plain string at runtime but a distinct type at compile time, so you can never pass a ProductId where an OrderId is expected — at zero runtime cost.

src/orders/order-item.value.ts
import type { Branded } from "@heximon/primitives";

export type ProductId = Branded<string, "ProductId">;
src/orders/order.entity.ts
import type { Branded } from "@heximon/primitives";

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

Model the value objects

A value object has no identity — it's compared by value, so two Money instances with the same amount and currency are the same money. ValueObject<MoneyValue> freezes the wrapped value and gives you structural equals and toJSON; the create factory holds the construction invariant, so a malformed Money can never be built.

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} (expected a non-negative integer)`);
    }
    if (!Money.currencyPattern.test(currency)) {
      throw new TypeError(`Invalid currency: ${currency} (expected an ISO-4217 code like "EUR")`);
    }
    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: ${this.currency} + ${other.currency}`);
    }
    return Money.create(this.amountCents + other.amountCents, this.currency);
  }

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

Read the wrapped value through this.getInternalValue(), never this.propsprops is the entity vocabulary, used below. An OrderItem nests a Money unit price, and equals recurses, so two items compare structurally with no extra code.

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

interface OrderItemValue {
  readonly productId: ProductId;
  readonly name: string;
  readonly unitPrice: Money;
  readonly quantity: number;
}

export class OrderItem extends ValueObject<OrderItemValue> {
  public static create(value: OrderItemValue): OrderItem {
    if (!Number.isInteger(value.quantity) || value.quantity < 1) {
      throw new RangeError(`Invalid quantity: ${value.quantity} (expected a positive integer)`);
    }
    return new OrderItem(value);
  }

  public get productId(): ProductId {
    return this.getInternalValue().productId;
  }

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

  public get unitPrice(): Money {
    return this.getInternalValue().unitPrice;
  }

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

  public subtotal(): Money {
    return this.unitPrice.multiply(this.quantity);
  }
}

A factory is one way to enforce the rule; a schema is another — class BirthDate extends ValueObject(v.pipe(v.string(), v.isoDate())) {} runs a Standard Schema on construction and renders a 400 on failure. Reach for it when the rule is a shape rather than logic; it must be synchronous, since construction can't await.

Declare the domain events

A DomainEvent is a plain class whose constructor parameters are its payload — no behavior, no dependency on the bus, just a record of what happened. You record these as the aggregate mutates, then flush them after saving.

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

There's no decorator and no manual registration — each event class gets a stable dispatch identity at build time, which is how a handler in another module finds it.

Build the aggregate root

The aggregate root is the consistency boundary: it owns its lines, enforces the invariants, and records domain events as it changes. AggregateRoot is an Entity (carrying an id and a version) that also buffers events — addDomainEvent(...) stashes one for a caller to drain later. It never touches the event bus, which keeps recording a testable side effect of a mutation, not a hidden one.

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

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 after save
    return order;
  }

  public get items(): OrderItem[] {
    return [...this.props.items]; // defensive copy — callers can't mutate the aggregate's array
  }

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

  public get shippedAt(): Date | undefined {
    return this.props.shippedAt;
  }

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

  public total(): Money {
    const [first, ...rest] = this.props.items;
    if (!first) {
      throw new CannotShipEmptyOrderError();
    }
    return rest.reduce((sum, item) => sum.add(item.subtotal()), first.subtotal());
  }

  public ship(): void {
    if (this.hasShipped()) {
      throw new CannotShipTwiceError(); // invariant: ship once
    }
    if (this.props.items.length === 0) {
      throw new CannotShipEmptyOrderError(); // invariant: never ship empty
    }

    this.props.status = OrderStatus.Shipped;
    this.props.shippedAt = new Date();
    this.addDomainEvent(new OrderShippedDomainEvent(this.id, this.props.shippedAt));
  }
}

ship() is the only door to the Shipped state — the invariant lives with the data, so no controller, test, or future caller can route around it. A violated invariant throws a DomainError, which extends the core HeximonError: throwing one from a handler renders an RFC 9457 problem response with the status you declare, no try/catch in the controller.

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

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

  public constructor() {
    super("Cannot ship an order with no items");
  }
}

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

  public constructor() {
    super("Order has already shipped");
  }
}

React from another context

The shipping context wants to know when an order ships, but has no business depending on the orders module. It subscribes by event identity instead — implements DomainEventHandler<OrderShippedDomainEvent> — and reads only the flat data the event carries, never the Order aggregate, so the two contexts share a shape and nothing else.

src/shipping/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() });
  }
}

Dispatch is in-process and serial, so a throw here propagates back to the caller that flushed the event — if recording the shipment fails, the whole operation fails together. The ShipmentTracker is a plain provider, injected by its constructor type with no token.

Flush events after saving

The aggregate buffers events but dispatches nothing on its own — there's no implicit unit of work, so you control when side effects fire: persist first, then flush. The controller injects the repository and the DomainEvents facade (a core provider you never declare), and calls emitFrom(order) after the save to drain the buffer.

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

interface CreateOrderBody {
  readonly items: ReadonlyArray<{
    readonly productId: string;
    readonly name: string;
    readonly unitPriceCents: number;
    readonly currency: string;
    readonly quantity: number;
  }>;
}

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

  public async create(action: Post<"/">): Promise<{ id: string; status: string }> {
    const body = (await action.request.readBody()) as CreateOrderBody;

    const items = body.items.map((line) =>
      OrderItem.create({
        productId: line.productId as ProductId,
        name: line.name,
        unitPrice: Money.create(line.unitPriceCents, line.currency), // validates on construction
        quantity: line.quantity,
      }),
    );

    const order = Order.create(crypto.randomUUID() as OrderId, items);
    this.repository.save(order);
    await this.domainEvents.emitFrom(order); // drains the buffer; dispatches serially, in order

    action.response.status = 201;
    return { id: order.id, status: order.status };
  }

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

    order.ship(); // enforces the invariants; a forbidden transition throws a 409
    this.repository.save(order);
    await this.domainEvents.emitFrom(order); // RecordShipmentHandler runs here

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

emitFrom(order) is the seam where the two contexts meet: it runs every matching DomainEventHandler in order, so the shipping read model updates as part of shipping the order. A second flush is a no-op.

Persist with optimistic concurrency

The repository is the aggregate's persistence boundary — an in-memory Map here, a DrizzleEntityRepository in production, with no change to the aggregate. Either way the version the Entity base carries gives you optimistic concurrency: each save checks and bumps the stored version, so two concurrent writes can't silently clobber each other — the loser gets a ConcurrencyError to retry.

src/orders/order.repository.ts
import type { Order, OrderId } from "./order.entity";

export class OrderRepository {
  private readonly orders = new Map<OrderId, Order>();

  public save(order: Order): void {
    this.orders.set(order.id, order);
  }

  public findById(id: OrderId): Order | undefined {
    return this.orders.get(id);
  }
}

Wire the two contexts

Each context is its own module, and they never name each other. The orders module declares its repository and HTTP surface; the shipping module declares its handler under the domain namespace's eventHandlers key — never in providers, since that key is how the compiler knows to dispatch the handler rather than just construct it. The DomainEvents facade is contributed app-wide, so no module declares it.

src/orders/orders.module.ts
import { Module } from "@heximon/runtime";
import { OrderRepository } from "./order.repository";
import { OrdersController } from "./orders.controller";

export class OrdersModule extends Module({
  providers: [OrderRepository],
  http: { controllers: [OrdersController] },
  exports: [OrderRepository],
}) {}
src/shipping/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] }, // the in-process domain-event tier
  exports: [ShipmentTracker],
}) {}
src/app.module.ts
import { Module } from "@heximon/runtime";
import { OrdersModule } from "./orders/orders.module";
import { ShippingModule } from "./shipping/shipping.module";

export class AppModule extends Module({
  imports: [OrdersModule, ShippingModule],
}) {}

A typo'd sub-key is a TypeScript error, caught before you run.

Register the plugins

Add DomainEventsPlugin alongside the HTTP plugin. It contributes the DomainEvents facade, wires domain-event dispatch, and pulls in the event bus tier DomainEventHandler rides on for you.

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

The heximon() plugin in vite.config.ts takes no arguments — it reads heximon.config.ts itself.

Run it

pnpm dev
terminal
# create a draft order from one line
curl -s -X POST localhost:3000/orders \
  -H 'content-type: application/json' \
  -d '{"items":[{"productId":"p1","name":"Widget","unitPriceCents":500,"currency":"EUR","quantity":2}]}'
# → 201 { "id": "...", "status": "Draft" }

# ship it → records OrderShippedDomainEvent → RecordShipmentHandler runs → shows up at GET /shipments
curl -s -X POST localhost:3000/orders/<id>/ship
# → 200 { "id": "...", "status": "Shipped" }

# ship it again → CannotShipTwiceError → RFC 9457 problem response
curl -s -X POST localhost:3000/orders/<id>/ship
# → 409 application/problem+json

Pitfalls

  • Mutate props directly — don't reassign the bag. Dirty tracking diffs the live props fields against a snapshot, so this.props.status = "Shipped" is exactly right; replacing the whole props object defeats the diff.
  • Always flush after saving. Forget await domainEvents.emitFrom(order) and RecordShipmentHandler never runs and GET /shipments stays empty, with no error to tell you why. Make it the line right after repository.save.
  • Read value objects through their accessors, never raw. order.total().amountCents, not the frozen internal object — the internal value is an implementation detail; the accessor is the contract.
  • Keep the contexts coupled only by the event. A DomainEventHandler should depend on the event's flat payload, not the originating aggregate. Reach into the other context's providers and you've traded the decoupling away.

See also

  • Domain-Driven Design — the full set of DDD primitives behind this recipe: entities, value objects, aggregates, and the two event tiers.
  • Event-Driven Module — the three event tiers compared, including when to reach past domain events for a queue-backed integration event across a service boundary.
  • Repository Pattern — how a repository loads and saves an aggregate while keeping the domain persistence-agnostic.
  • Build a CRUD API — map this aggregate to a Drizzle table and drive it through a typed controller with real optimistic-concurrency saves.
  • the ladder's L06 — DDD — the same aggregate-root + domain-event pattern this recipe builds (a User aggregate instead of Order), with an end-to-end test that creates a user, proves the decoupled onboarding context's cross-context fan-out, and asserts the optimistic-concurrency ConcurrencyError on a stale save.
Copyright © 2026