Build a DDD Aggregate
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.
import type { Branded } from "@heximon/primitives";
export type ProductId = Branded<string, "ProductId">;
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.
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.props — props 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.
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.
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.
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.
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.
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.
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.
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.
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],
}) {}
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],
}) {}
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.
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
npm run dev
yarn run dev
bun run dev
# 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
propsdirectly — don't reassign the bag. Dirty tracking diffs the livepropsfields against a snapshot, sothis.props.status = "Shipped"is exactly right; replacing the wholepropsobject defeats the diff. - Always flush after saving. Forget
await domainEvents.emitFrom(order)andRecordShipmentHandlernever runs andGET /shipmentsstays empty, with no error to tell you why. Make it the line right afterrepository.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
DomainEventHandlershould 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
Useraggregate instead ofOrder), with an end-to-end test that creates a user, proves the decoupled onboarding context's cross-context fan-out, and asserts the optimistic-concurrencyConcurrencyErroron a stale save.
Decouple Features with Events
Wire an event-driven module with EventBus, EventHandler and NotificationHandler, the events namespace, DomainEvents, and IntegrationEventHandler across the in-process, domain, and integration tiers.
Publish a Feature Package
Ship a feature as a precompiled, publishable Heximon module — build the factory and manifest with heximonLibrary(), require host tokens, then consume it in one import.