Domain-Driven Design
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.
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.
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 bus — addDomainEvent(...) 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 type — Branded<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:
| State | Meaning | Write on save() |
|---|---|---|
Detached | Newly created, never persisted | INSERT |
Persistent | In sync with the store | No-op |
Transient | Persisted, then mutated | UPDATE (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:
// 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.
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.
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:
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:
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:
| Contract | Direction | Method |
|---|---|---|
ResponseMapper<T> | domain object → response DTO | toResponse |
PersistenceMapper | domain ↔ persisted record | toDomain / toPersistence |
EntityPersistenceMapper | entity ↔ row, rehydrating through the base | toDomain / 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.
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.
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
curlsequence. - CQRS — drive aggregate writes through a
CommandBusand reads through aQueryBus, 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
ConcurrencyErrorhierarchy and how a dialect enforces the version guard underneathsave(). - Controllers — the HTTP surface that turns requests into aggregate operations and flushes their events.
- L06 — DDD
— a runnable
Useraggregate with anEmailvalue object, a brandedUserId, optimistic-concurrency versioning, and aUserRegisteredDomainEventflushed across to a decoupled onboarding context.
Overview
When to reach for CQRS, Domain-Driven Design, the repository pattern, integration events, sagas, and workflows — and when a plain controller and a plain provider are still the right call.
CQRS
Split writes from reads with a typed CommandBus and QueryBus, one handler per message, around-chain interceptors, and automatic ConcurrencyError retry.