noddde

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:

StepEventSaga Reaction
1OrderPlacedDispatch RequestPayment
2aPaymentCompletedDispatch ConfirmOrder + ArrangeShipment
2bPaymentFailedDispatch CancelOrder
3ShipmentDispatchedDispatch MarkOrderShipped
4ShipmentDeliveredDispatch MarkOrderDelivered + notify customer
CompensationOrderCancelledDispatch 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

On this page