Order Fulfillment Domain
A complete e-commerce order fulfillment example using three aggregates and a saga.
This example demonstrates a full e-commerce order fulfillment domain with three aggregates (Order, Payment, Shipping) and a saga that coordinates them. It exercises every saga capability: multi-aggregate coordination, conditional commands, multi-command dispatch, async handlers with infrastructure, and compensation.
Full source: samples/sample-orders — clone and run locally with
npm start.
Domain Overview
OrderFulfillmentSaga
|
┌────────────┼────────────┐
v v v
[Order] [Payment] [Shipping]The saga listens to events from all three aggregates and dispatches commands to coordinate the workflow:
| Step | Event | Saga Reaction |
|---|---|---|
| 1 | OrderPlaced | Dispatch RequestPayment |
| 2a | PaymentCompleted | Dispatch ConfirmOrder + ArrangeShipment |
| 2b | PaymentFailed | Dispatch CancelOrder |
| 3 | ShipmentDispatched | Dispatch MarkOrderShipped |
| 4 | ShipmentDelivered | Dispatch MarkOrderDelivered + notify customer |
| Compensation | OrderCancelled | Dispatch RefundPayment (if payment taken) |
Events
Each aggregate defines its own events using DefineEvents:
// order/events.ts
type OrderEvent = DefineEvents<{
OrderPlaced: {
orderId: string;
customerId: string;
items: OrderItem[];
total: number;
placedAt: Date;
};
OrderConfirmed: { orderId: string; confirmedAt: Date };
OrderCancelled: { orderId: string; reason: string; cancelledAt: Date };
OrderShipped: { orderId: string; trackingNumber: string; shippedAt: Date };
OrderDelivered: { orderId: string; deliveredAt: Date };
}>;
// payment/events.ts — uses "referenceId" (generic payment reference, not order-specific)
type PaymentEvent = DefineEvents<{
PaymentRequested: {
paymentId: string;
referenceId: string;
amount: number;
requestedAt: Date;
};
PaymentCompleted: {
paymentId: string;
referenceId: string;
amount: number;
completedAt: Date;
};
PaymentFailed: {
paymentId: string;
referenceId: string;
reason: string;
failedAt: Date;
};
PaymentRefunded: {
paymentId: string;
referenceId: string;
amount: number;
reason: string;
refundedAt: Date;
};
}>;
// shipping/events.ts — uses "customerReference" (external order reference)
type ShippingEvent = DefineEvents<{
ShipmentArranged: {
shipmentId: string;
customerReference: string;
itemCount: number;
arrangedAt: Date;
};
ShipmentDispatched: {
shipmentId: string;
customerReference: string;
trackingNumber: string;
dispatchedAt: Date;
};
ShipmentDelivered: {
shipmentId: string;
customerReference: string;
deliveredAt: Date;
};
}>;Commands
Each aggregate defines its commands:
// order/commands.ts
type OrderCommand = DefineCommands<{
PlaceOrder: { customerId: string; items: OrderItem[] };
ConfirmOrder: void;
CancelOrder: { reason: string };
MarkOrderShipped: { trackingNumber: string };
MarkOrderDelivered: void;
}>;
// payment/commands.ts
type PaymentCommand = DefineCommands<{
RequestPayment: { referenceId: string; amount: number };
CompletePayment: void;
FailPayment: { reason: string };
RefundPayment: { reason: string };
}>;
// shipping/commands.ts
type ShippingCommand = DefineCommands<{
ArrangeShipment: { customerReference: string; itemCount: number };
DispatchShipment: { trackingNumber: string };
ConfirmDelivery: void;
}>;The Saga
The saga's SagaTypes bundle spans all three aggregates:
type OrderFulfillmentSagaDef = {
state: OrderFulfillmentState;
events: OrderEvent | PaymentEvent | ShippingEvent;
commands: OrderCommand | PaymentCommand | ShippingCommand;
infrastructure: EcommerceInfrastructure;
};Key saga features demonstrated:
Multi-command dispatch
When payment completes, the saga dispatches two commands — one to confirm the order and one to arrange shipping:
PaymentCompleted: (event, state) => ({
state: { ...state, status: "awaiting_shipment" },
commands: [
{ name: "ConfirmOrder", targetAggregateId: state.orderId! },
{ name: "ArrangeShipment", targetAggregateId: shipmentId, payload: { ... } },
],
}),Conditional compensation
When an order is cancelled, the saga only refunds if payment was already taken:
OrderCancelled: (event, state) => ({
state: { ...state, status: "cancelled" },
commands: state.paymentId
? { name: "RefundPayment", targetAggregateId: state.paymentId, payload: { ... } }
: undefined,
}),Async handlers with infrastructure
When delivery is confirmed, the saga notifies the customer via infrastructure:
ShipmentDelivered: async (_event, state, { notificationService }) => {
await notificationService.notifyCustomer(
state.customerId!,
`Your order ${state.orderId} has been delivered!`,
);
return {
state: { ...state, status: "delivered" },
commands: { name: "MarkOrderDelivered", targetAggregateId: state.orderId! },
};
},Domain Configuration
The full domain wires three aggregates, a projection, a saga, and all infrastructure:
const domain = await configureDomain<EcommerceInfrastructure>({
writeModel: {
aggregates: { Order, Payment, Shipping },
},
readModel: {
projections: { OrderSummary: OrderSummaryProjection },
},
processModel: {
sagas: { OrderFulfillment: OrderFulfillmentSaga },
},
infrastructure: {
aggregatePersistence: () => new InMemoryEventSourcedAggregatePersistence(),
sagaPersistence: () => new InMemorySagaPersistence(),
provideInfrastructure: () => ({
clock: new SystemClock(),
notificationService: new ConsoleNotificationService(),
orderSummaryRepository: new InMemoryOrderSummaryRepository(),
}),
cqrsInfrastructure: () => ({
commandBus: new InMemoryCommandBus(),
eventBus: new EventEmitterEventBus(),
queryBus: new InMemoryQueryBus(),
}),
},
});See Also
- Sagas Overview — Conceptual introduction to sagas
- Defining Sagas — Full API reference
- Banking Domain — A simpler example without sagas
- Clock Pattern — The infrastructure injection pattern used in this example