Skip to content

Putting It All Together

This walkthrough builds a complete Order Management domain using every DDD pattern covered in this section. We’ll model an e-commerce order lifecycle — placing orders, processing payments, managing fulfillment, and building read models.

DDD Order Flow — command triggers aggregate, events flow to projections, saga, and integration

Our Order Management system handles:

  • Placing orders — customers submit orders with items and shipping address
  • Processing payments — charge the customer via a payment gateway
  • Fulfilling orders — reserve inventory and create shipping labels
  • Tracking status — real-time order status for customers and internal dashboards
  • Notifying external systems — send events to notification and analytics services

The Order aggregate is an entity stream. Its identity is the order ID, and its state is derived from the sequence of domain events.

Events in the Order aggregate:

EventMeaning
order.placedCustomer submitted an order
order.confirmedInventory validated, order accepted
order.paidPayment charged successfully
order.shippedShipment created and dispatched
order.cancelledOrder cancelled (before shipment)
import { createClient } from "@ironflow/node";
const ironflow = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
// Place a new order — create the aggregate
async function placeOrder(orderId: string, data: OrderData) {
await ironflow.streams.append(`order-${orderId}`, {
entityType: "order",
name: "order.placed",
data: {
orderId,
customerId: data.customerId,
items: data.items,
total: data.total,
shippingAddress: data.address,
},
}, { expectedVersion: 0 }); // Must be a new aggregate
}
// Transition the aggregate — with invariant enforcement
async function confirmOrder(orderId: string) {
const { events } = await ironflow.streams.read(`order-${orderId}`);
const info = await ironflow.streams.getInfo(`order-${orderId}`);
const version = info?.version ?? 0;
const state = rebuildOrderState(events);
if (state.status !== "placed") {
throw new Error(`Cannot confirm order in status: ${state.status}`);
}
await ironflow.streams.append(`order-${orderId}`, {
entityType: "order",
name: "order.confirmed",
data: { confirmedAt: new Date().toISOString() },
}, { expectedVersion: version });
}
function rebuildOrderState(events: any[]) {
return events.reduce((state, event) => {
switch (event.name) {
case "order.placed":
return { ...event.data, status: "placed" };
case "order.confirmed":
return { ...state, status: "confirmed" };
case "order.paid":
return { ...state, status: "paid" };
case "order.shipped":
return { ...state, status: "shipped" };
case "order.cancelled":
return { ...state, status: "cancelled" };
default:
return state;
}
}, { status: "unknown" });
}

Functions react to domain events and execute business logic. The order.placed event triggers the confirmation workflow:

import { ironflow } from "@ironflow/node";
import { NonRetryableError } from "@ironflow/core";
export const validateOrder = ironflow.createFunction(
{
id: "validate-order",
triggers: [{ event: "order.placed" }],
},
async ({ event, step }) => {
// Check inventory availability
const available = await step.run("check-inventory", async () => {
return await inventoryService.checkAvailability(event.data.items);
});
if (!available) {
// Cancel the order — append to the aggregate
await step.run("cancel-order", async () => {
await ironflow.streams.append(`order-${event.data.orderId}`, {
entityType: "order",
name: "order.cancelled",
data: { reason: "Items out of stock" },
});
});
return;
}
// Confirm the order
await step.run("confirm-order", async () => {
await confirmOrder(event.data.orderId);
});
},
);

A managed projection builds the OrderDashboard read model — an aggregated view of all orders, updated in real time:

import { createProjection } from "@ironflow/node";
export const orderDashboard = createProjection({
name: "order-dashboard",
events: ["order.placed", "order.confirmed", "order.paid", "order.shipped", "order.cancelled"],
handler: (state, event) => {
const orderId = event.data.orderId;
const existing = state.orders[orderId] ?? {};
switch (event.name) {
case "order.placed":
return {
...state,
totalOrders: state.totalOrders + 1,
orders: {
...state.orders,
[orderId]: {
id: orderId,
customerId: event.data.customerId,
total: event.data.total,
status: "placed",
placedAt: event.timestamp,
},
},
};
case "order.confirmed":
case "order.paid":
case "order.shipped":
case "order.cancelled":
return {
...state,
orders: {
...state.orders,
[orderId]: {
...existing,
status: event.name.split(".")[1],
},
},
};
default:
return state;
}
},
initialState: () => ({ totalOrders: 0, orders: {} }),
});

A partitioned projection gives per-customer analytics:

export const customerOrders = createProjection({
name: "customer-orders",
events: ["order.placed", "order.shipped"],
partitionKey: "$.data.customerId",
handler: (state, event) => ({
...state,
orderCount: state.orderCount + (event.name === "order.placed" ? 1 : 0),
shippedCount: state.shippedCount + (event.name === "order.shipped" ? 1 : 0),
totalSpend: state.totalSpend + (event.data.total ?? 0),
}),
initialState: () => ({ orderCount: 0, shippedCount: 0, totalSpend: 0 }),
});

The fulfillment saga runs when an order is confirmed — it charges payment, reserves inventory, and creates the shipment:

import { NonRetryableError } from "@ironflow/core";
export const fulfillOrder = ironflow.createFunction(
{
id: "fulfill-order",
triggers: [{ event: "order.confirmed" }],
},
async ({ event, step }) => {
const orderId = event.data.orderId;
// Step 1: Charge payment
const payment = await step.run("charge-payment", async () => {
return await stripe.charges.create({
amount: event.data.total,
customer: event.data.customerId,
});
});
step.compensate("charge-payment", async () => {
await stripe.refunds.create({ charge: payment.id });
});
// Record payment in aggregate
await step.run("record-payment", async () => {
await ironflow.streams.append(`order-${orderId}`, {
entityType: "order",
name: "order.paid",
data: { paymentId: payment.id },
});
});
// Step 2: Reserve inventory
const reservation = await step.run("reserve-inventory", async () => {
return await inventoryService.reserve(event.data.items);
});
step.compensate("reserve-inventory", async () => {
await inventoryService.release(reservation.id);
});
// Step 3: Create shipment
const shipment = await step.run("create-shipment", async () => {
const result = await shippingService.createLabel(event.data.address);
if (!result.ok) {
throw new NonRetryableError("Shipping unavailable");
}
return result;
});
// Record shipment in aggregate
await step.run("record-shipment", async () => {
await ironflow.streams.append(`order-${orderId}`, {
entityType: "order",
name: "order.shipped",
data: { trackingNumber: shipment.trackingNumber },
});
});
},
);

After the order ships, publish integration events for external systems:

export const notifyShipment = ironflow.createFunction(
{
id: "notify-shipment",
triggers: [{ event: "order.shipped" }],
},
async ({ event, step }) => {
// Publish integration events — lean, cross-context
// step.publish() is durable and memoized
await step.publish("notifications.shipment", {
orderId: event.data.orderId,
trackingNumber: event.data.trackingNumber,
});
await step.publish("analytics.revenue", {
orderId: event.data.orderId,
total: event.data.total,
});
},
);

Wire up all functions and projections in your worker:

import { createWorker } from "@ironflow/node";
const worker = createWorker({
functions: [validateOrder, fulfillOrder, notifyShipment],
projections: [orderDashboard, customerOrders],
});
await worker.start();
DDD PatternIronflow PrimitiveThis Example
AggregateEntity Streamorder-{id} stream with placed/confirmed/paid/shipped events
Command HandlerEvent-triggered Functionvalidate-order reacts to order.placed
Domain Eventsemit() + appendToStream()order.confirmed, order.paid, order.shipped
Read Model (CQRS)Managed Projectionorder-dashboard, customer-orders
SagaWorkflow with step.compensate()fulfill-order with payment/inventory/shipping
Integration Eventsstep.publish()notifications.shipment, analytics.revenue

Previous pages in this section: