noddde

Hotel Booking Domain

A full-stack hotel booking example with three aggregates, three sagas, projections, Fastify HTTP, and Drizzle persistence.

This example walks through the hotel booking sample — the most complete demonstration in the repository. It covers three aggregates, a three-saga process chain, projections with custom view store interfaces, per-aggregate persistence strategies, unit-of-work transactions, and a Fastify HTTP layer backed by Drizzle + PostgreSQL.

Full source: samples/sample-hotel-booking — clone and run locally with npm start.

Domain Overview

The domain models a hotel's reservation workflow from booking creation through check-in and checkout. Three sagas form an automated chain that runs entirely without human intervention after a guest submits a booking request.

Guest creates booking


 BookingFulfillmentSaga   ←── starts on BookingCreated

        │  dispatches RequestPayment

 PaymentProcessingSaga    ←── starts on PaymentRequested

        │  calls paymentGateway.charge()
        │  dispatches CompletePayment | FailPayment

 BookingFulfillmentSaga   ←── resumes on PaymentCompleted | PaymentFailed

        │  queries for available room
        │  dispatches ConfirmBooking + ReserveRoom  (success path)
        │  dispatches CancelBooking                  (no room / payment failed)

 CheckoutReminderSaga     ←── starts on GuestCheckedIn

        │  sends SMS on check-in and check-out

      (end)

Three aggregates support the write model:

AggregateResponsibilityPersistence
BookingPayment state machine for one bookingEvent-sourced
RoomPhysical room lifecycle (reserve → occupy)Event-sourced
InventoryAggregate availability counts by typeState-stored

Write Model

Booking aggregate — payment state machine

The Booking aggregate is the most instructive of the three. Its status field is a strict state machine: every payment command validates the current status before emitting an event. All handlers are extracted to separate files typed with InferDecideHandler and InferEvolveHandler.

Decide handlers (extracted)

// deciders/decide-request-payment.ts
import type { InferDecideHandler } from "@noddde/core";
import type { BookingDef } from "../booking";

// Transition: pending → awaiting_payment
export const decideRequestPayment: InferDecideHandler<
  BookingDef,
  "RequestPayment"
> = (command, state) => {
  if (state.status !== "pending") {
    throw new Error(`Cannot request payment in ${state.status} status`);
  }
  return {
    name: "PaymentRequested",
    payload: {
      bookingId: command.targetAggregateId,
      guestId: state.guestId!,
      paymentId: command.payload.paymentId,
      amount: command.payload.amount,
    },
  };
};
// deciders/decide-complete-payment.ts
import type { InferDecideHandler } from "@noddde/core";
import type { BookingDef } from "../booking";

// Transition: awaiting_payment → (transactionId recorded)
export const decideCompletePayment: InferDecideHandler<
  BookingDef,
  "CompletePayment"
> = (command, state, { clock }) => {
  if (state.status !== "awaiting_payment") {
    throw new Error(`Cannot complete payment in ${state.status} status`);
  }
  return {
    name: "PaymentCompleted",
    payload: {
      bookingId: command.targetAggregateId,
      paymentId: command.payload.paymentId,
      transactionId: command.payload.transactionId,
      amount: command.payload.amount,
      completedAt: clock.now().toISOString(),
    },
  };
};

The remaining decide handlers (FailPayment, RefundPayment, CreateBooking, ConfirmBooking, CancelBooking, ModifyBooking) follow the same pattern — each in its own file, each typed with InferDecideHandler<BookingDef, "CommandName">.

Evolve handlers (extracted)

// evolvers/evolve-payment-requested.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { BookingDef } from "../booking";

export const evolvePaymentRequested: InferEvolveHandler<
  BookingDef,
  "PaymentRequested"
> = (event, state) => ({
  ...state,
  status: "awaiting_payment" as const,
  paymentId: event.paymentId,
});
// evolvers/evolve-payment-failed.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { BookingDef } from "../booking";

export const evolvePaymentFailed: InferEvolveHandler<
  BookingDef,
  "PaymentFailed"
> = (_event, state) => ({
  ...state,
  status: "pending" as const, // reset: payment can be reattempted
  paymentId: null,
});

Notice that PaymentFailed resets status back to "pending" rather than "cancelled". This lets the guest retry payment without creating a new booking. The other evolve handlers (PaymentCompleted, BookingCreated, BookingConfirmed, BookingCancelled, BookingModified, PaymentRefunded) follow the same pattern.

Aggregate definition (wiring)

The aggregate definition imports all handlers and wires them. This file contains no business logic — only composition.

// booking.ts
import { defineAggregate } from "@noddde/core";

type BookingDef = {
  state: BookingState;
  events: BookingEvent;
  commands: BookingCommand;
  infrastructure: HotelInfrastructure;
};

export const Booking = defineAggregate<BookingDef>({
  initialState: initialBookingState,

  decide: {
    CreateBooking: decideCreateBooking,
    ConfirmBooking: decideConfirmBooking,
    CancelBooking: decideCancelBooking,
    ModifyBooking: decideModifyBooking,
    RequestPayment: decideRequestPayment,
    CompletePayment: decideCompletePayment,
    FailPayment: decideFailPayment,
    RefundPayment: decideRefundPayment,
  },

  evolve: {
    BookingCreated: evolveBookingCreated,
    BookingConfirmed: evolveBookingConfirmed,
    BookingCancelled: evolveBookingCancelled,
    BookingModified: evolveBookingModified,
    PaymentRequested: evolvePaymentRequested,
    PaymentCompleted: evolvePaymentCompleted,
    PaymentFailed: evolvePaymentFailed,
    PaymentRefunded: evolvePaymentRefunded,
  },
});

Room and Inventory aggregates

Room (event-sourced) tracks the physical lifecycle of a single room: created → available → reserved → occupied → available. It emits RoomReserved, GuestCheckedIn, and GuestCheckedOut events that the CheckoutReminderSaga and projections consume.

Inventory (state-stored) maintains aggregate counts of available rooms by type (single, double, suite). Because only the current totals matter — not the full history — state-stored persistence is the right choice here. See Why Two Persistence Strategies for the trade-off.

Saga Chain

Sagas are the main teaching content of this sample. Three independent sagas collaborate through the event bus to implement the full booking lifecycle without any direct coupling between them.

BookingFulfillmentSaga

This saga orchestrates the top-level booking flow. It starts on BookingCreated, waits for payment to complete, then queries for an available room and dispatches commands to two different aggregates atomically. Like aggregates, sagas use extracted transition handlers — each event handler lives in its own file.

Transition handlers (extracted)

// on-entries/on-booking-created.ts
import { randomUUID } from "crypto";
import type { BookingFulfillmentState } from "../state";

export const onBookingCreated = (event: {
  payload: {
    bookingId: string;
    guestId: string;
    roomType: any;
    checkIn: string;
    checkOut: string;
    totalAmount: number;
    createdAt: string;
  };
}) => {
  const paymentId = randomUUID();
  return {
    state: {
      bookingId: event.payload.bookingId,
      guestId: event.payload.guestId,
      roomType: event.payload.roomType,
      checkIn: event.payload.checkIn,
      checkOut: event.payload.checkOut,
      totalAmount: event.payload.totalAmount,
      paymentId,
      roomId: null,
      status: "awaiting_payment" as const,
    } satisfies BookingFulfillmentState,
    decide: {
      name: "RequestPayment" as const,
      targetAggregateId: event.payload.bookingId,
      payload: { paymentId, amount: event.payload.totalAmount },
    },
  };
};
// on-entries/on-payment-completed.ts
import type { BookingFulfillmentState } from "../state";

export const onPaymentCompleted = async (
  event: { payload: { bookingId: string /* ... */ } },
  state: BookingFulfillmentState,
  infrastructure: any,
) => {
  const availableRooms = (await infrastructure.queryBus.dispatch({
    name: "SearchAvailableRooms",
    payload: { type: state.roomType },
  })) as any[];

  const room = availableRooms?.[0];
  if (!room) {
    return {
      state: { ...state, status: "cancelled" as const },
      decide: {
        name: "CancelBooking" as const,
        targetAggregateId: state.bookingId,
        payload: { reason: "No available room of requested type" },
      },
    };
  }

  // Multi-command dispatch across two aggregates
  return {
    state: { ...state, status: "confirmed" as const, roomId: room.roomId },
    commands: [
      {
        name: "ConfirmBooking" as const,
        targetAggregateId: state.bookingId,
        payload: { roomId: room.roomId },
      },
      {
        name: "ReserveRoom" as const,
        targetAggregateId: room.roomId,
        payload: {
          bookingId: state.bookingId,
          guestId: state.guestId,
          checkIn: state.checkIn,
          checkOut: state.checkOut,
        },
      },
    ],
  };
};
// on-entries/on-booking-cancelled.ts
import type { BookingFulfillmentState } from "../state";

export const onBookingCancelled = (
  _event: {
    payload: { bookingId: string; reason: string; cancelledAt: string };
  },
  state: BookingFulfillmentState,
) => ({
  state: { ...state, status: "cancelled" as const },
  commands:
    state.status === "confirmed" && state.paymentId
      ? {
          name: "RefundPayment" as const,
          targetAggregateId: state.bookingId,
          payload: { paymentId: state.paymentId, amount: state.totalAmount },
        }
      : undefined, // no refund if payment never completed
});

The PaymentFailed, BookingConfirmed, BookingModified, PaymentRequested, and PaymentRefunded handlers follow the same pattern — each in its own file under on-entries/.

Saga definition (wiring)

// saga.ts
import { defineSaga } from "@noddde/core";
import {
  onBookingCancelled,
  onBookingConfirmed,
  onBookingCreated,
  onBookingModified,
  onPaymentCompleted,
  onPaymentFailed,
  onPaymentRefunded,
  onPaymentRequested,
} from "./on-entries";

type BookingFulfillmentDef = {
  state: BookingFulfillmentState;
  events: BookingEvent;
  commands: BookingCommand | RoomCommand;
  infrastructure: HotelInfrastructure;
};

export const BookingFulfillmentSaga = defineSaga<BookingFulfillmentDef>({
  initialState: initialBookingFulfillmentState,
  startedBy: ["BookingCreated"],

  on: {
    BookingCreated: onBookingCreated,
    PaymentCompleted: onPaymentCompleted,
    PaymentFailed: onPaymentFailed,
    BookingCancelled: onBookingCancelled,
    BookingConfirmed: onBookingConfirmed,
    BookingModified: onBookingModified,
    PaymentRequested: onPaymentRequested,
    PaymentRefunded: onPaymentRefunded,
  },
});

Two patterns to note here:

  • Multi-command dispatch: returning an array from onPaymentCompleted dispatches both commands before the saga persists its updated state. ConfirmBooking and ReserveRoom target different aggregates.
  • Conditional compensation: the onBookingCancelled handler checks state.status === "confirmed" before issuing a refund. If payment never completed, the command is undefined and nothing is dispatched.

PaymentProcessingSaga

This saga bridges the RequestPayment command (dispatched by BookingFulfillmentSaga) and the external payment gateway. It starts on PaymentRequested, calls the gateway synchronously, and closes the loop by dispatching CompletePayment or FailPayment back to the Booking aggregate. Its transition handlers are extracted the same way.

Transition handlers (extracted)

// on-entries/on-payment-requested.ts
import type { PaymentProcessingState } from "../state";

export const onPaymentRequested = async (
  event: {
    payload: {
      bookingId: string;
      guestId: string;
      paymentId: string;
      amount: number;
    };
  },
  _state: PaymentProcessingState,
  {
    paymentGateway,
  }: {
    paymentGateway: {
      charge(
        guestId: string,
        amount: number,
      ): Promise<{ transactionId: string }>;
    };
  },
) => {
  try {
    const { transactionId } = await paymentGateway.charge(
      event.payload.guestId,
      event.payload.amount,
    );

    return {
      state: {
        bookingId: event.payload.bookingId,
        guestId: event.payload.guestId,
        paymentId: event.payload.paymentId,
        amount: event.payload.amount,
        status: "charging" as const,
      } satisfies PaymentProcessingState,
      decide: {
        name: "CompletePayment" as const,
        targetAggregateId: event.payload.bookingId,
        payload: {
          paymentId: event.payload.paymentId,
          transactionId,
          amount: event.payload.amount,
        },
      },
    };
  } catch (error: any) {
    return {
      state: {
        bookingId: event.payload.bookingId,
        guestId: event.payload.guestId,
        paymentId: event.payload.paymentId,
        amount: event.payload.amount,
        status: "failed" as const,
      } satisfies PaymentProcessingState,
      decide: {
        name: "FailPayment" as const,
        targetAggregateId: event.payload.bookingId,
        payload: {
          paymentId: event.payload.paymentId,
          reason: error.message ?? "Payment gateway error",
        },
      },
    };
  }
};

The PaymentCompleted and PaymentFailed handlers observe terminal states for saga record-keeping — they update the saga's status without dispatching commands.

Saga definition (wiring)

// saga.ts
import { defineSaga } from "@noddde/core";
import {
  onPaymentRequested,
  onPaymentCompleted,
  onPaymentFailed,
} from "./on-entries";

export const PaymentProcessingSaga = defineSaga<PaymentProcessingDef>({
  initialState: initialPaymentProcessingState,
  startedBy: ["PaymentRequested"],

  on: {
    PaymentRequested: onPaymentRequested,
    PaymentCompleted: onPaymentCompleted,
    PaymentFailed: onPaymentFailed,
  },
});

The saga's event type is narrowed with Extract to only the three payment lifecycle events — PaymentRequested, PaymentCompleted, and PaymentFailed. This keeps the saga focused and prevents accidental coupling to unrelated booking events.

In production, the paymentGateway.charge() call would be replaced by an async provider integration (for example, a Stripe webhook). The saga's persisted state would correlate the callback to the correct booking. See Sagas for how saga state is persisted and correlated.

CheckoutReminderSaga

The third saga is simpler: it starts on GuestCheckedIn, sends a welcome SMS via smsService, and sends a farewell SMS on GuestCheckedOut. It demonstrates infrastructure side effects from a saga without dispatching any commands.

Read Model

Custom ViewStore interface

The RoomAvailabilityViewStore extends the built-in ViewStore interface with a domain-specific findAvailable method. This pushes filtering to the database rather than loading all views into memory.

// domain/read-model/queries.ts
export interface RoomAvailabilityViewStore
  extends ViewStore<RoomAvailabilityView> {
  /** Finds available rooms, optionally filtered by room type. */
  findAvailable(type?: RoomType): Promise<RoomAvailabilityView[]>;
}

The Drizzle implementation (DrizzleRoomAvailabilityViewStore) translates this into a SQL WHERE status = 'available' clause. The in-memory fallback would load all views and filter in JavaScript — both are valid implementations behind the same interface.

RoomAvailabilityProjection — extracted event handlers

Projections follow the same extracted pattern. Each event handler is in its own file, typed with InferProjectionEventHandler.

Event handlers (extracted)

// on-entries/on-room-created.ts
import type { InferProjectionEventHandler } from "@noddde/core";
import type { RoomAvailabilityProjectionDef } from "../room-availability";

export const onRoomCreated: InferProjectionEventHandler<
  RoomAvailabilityProjectionDef,
  "RoomCreated"
> = {
  id: (event) => event.payload.roomId,
  reduce: (event) => ({
    roomId: event.payload.roomId,
    roomNumber: event.payload.roomNumber,
    type: event.payload.type,
    floor: event.payload.floor,
    pricePerNight: event.payload.pricePerNight,
    status: "created",
    currentGuestId: null,
  }),
};
// on-entries/on-room-reserved.ts
import type { InferProjectionEventHandler } from "@noddde/core";
import type { RoomAvailabilityProjectionDef } from "../room-availability";

export const onRoomReserved: InferProjectionEventHandler<
  RoomAvailabilityProjectionDef,
  "RoomReserved"
> = {
  id: (event) => event.payload.roomId,
  reduce: (event, view) => ({
    ...view,
    status: "reserved",
    currentGuestId: event.payload.guestId,
  }),
};

The RoomMadeAvailable, GuestCheckedIn, GuestCheckedOut, and RoomUnderMaintenance handlers follow the same pattern.

Projection definition (wiring)

// room-availability.ts
import { defineProjection } from "@noddde/core";
import {
  onRoomCreated,
  onRoomMadeAvailable,
  onRoomReserved,
  onGuestCheckedIn,
  onGuestCheckedOut,
  onRoomUnderMaintenance,
} from "./on-entries";
import {
  handleGetRoomAvailability,
  handleListAvailableRooms,
} from "./query-handlers";

type RoomAvailabilityProjectionDef = {
  events: RoomEvent;
  queries: RoomAvailabilityQuery;
  view: RoomAvailabilityView;
  infrastructure: HotelInfrastructure;
  viewStore: RoomAvailabilityViewStore;
};

export const RoomAvailabilityProjection =
  defineProjection<RoomAvailabilityProjectionDef>({
    consistency: "strong",
    initialView: {
      /* ... */
    },

    on: {
      RoomCreated: onRoomCreated,
      RoomMadeAvailable: onRoomMadeAvailable,
      RoomReserved: onRoomReserved,
      GuestCheckedIn: onGuestCheckedIn,
      GuestCheckedOut: onGuestCheckedOut,
      RoomUnderMaintenance: onRoomUnderMaintenance,
    },

    queryHandlers: {
      GetRoomAvailability: handleGetRoomAvailability,
      ListAvailableRooms: handleListAvailableRooms,
    },
  });

Strong vs eventual consistency

The sample shows both consistency modes side by side:

  • RoomAvailabilityProjection is registered as a domain projection. The engine runs it synchronously during command dispatch, so the view is always up to date before the decide handler returns. Queries against it reflect the current write-model state.
  • GuestHistoryProjection and RevenueProjection are also registered projections but use InMemoryViewStore — illustrating that consistency level is a persistence choice, not a projection design choice.

Standalone query handler

SearchAvailableRoomsHandler is registered as a standalone query handler rather than a projection-backed query. It reads directly from roomAvailabilityViewStore via infrastructure injection:

// domain/read-model/query-handlers.ts
export const SearchAvailableRoomsHandler: QueryHandler<
  HotelInfrastructure,
  Extract<SearchQuery, { name: "SearchAvailableRooms" }>
> = async (query, infrastructure) =>
  infrastructure.roomAvailabilityViewStore.findAvailable(query.type);

The handler is then consumed inside BookingFulfillmentSaga via infrastructure.queryBus.dispatch(...) — the same query bus that HTTP clients use. This is how the saga finds an available room after payment completes.

See Queries for the full standalone query handler pattern.

Domain Configuration

The domain configuration uses defineDomain to capture the pure domain structure, then wireDomain to connect infrastructure.

// src/main.ts
import { DrizzleAdapter } from "@noddde/drizzle";
import {
  defineDomain,
  wireDomain,
  InMemoryCommandBus,
  InMemoryQueryBus,
} from "@noddde/engine";
import { RabbitMqEventBus } from "@noddde/rabbitmq";

const adapter = new DrizzleAdapter(db);

// Step 1: Define the domain structure (pure, sync)
const hotelDomain = defineDomain({
  writeModel: {
    aggregates: { Room, Booking, Inventory },
    standaloneCommandHandlers: { RunNightlyAudit: RunNightlyAuditHandler },
  },

  readModel: {
    projections: {
      RoomAvailability: RoomAvailabilityProjection,
      GuestHistory: GuestHistoryProjection,
      Revenue: RevenueProjection,
    },
    standaloneQueryHandlers: {
      SearchAvailableRooms: SearchAvailableRoomsHandler,
    },
  },

  processModel: {
    sagas: {
      BookingFulfillment: BookingFulfillmentSaga,
      CheckoutReminder: CheckoutReminderSaga,
      PaymentProcessing: PaymentProcessingSaga,
    },
  },
});

// Step 2: Wire with infrastructure (async)
const domain = await wireDomain(hotelDomain, {
  persistenceAdapter: adapter,

  infrastructure: (): HotelInfrastructure => ({
    clock: new SystemClock(),
    paymentGateway: new FakePaymentGateway(),
    roomAvailabilityViewStore: new DrizzleRoomAvailabilityViewStore(db),
    // ...emailService, smsService, guestHistoryViewStore, revenueViewStore
  }),

  // Per-aggregate persistence: Room + Booking → event-sourced, Inventory → state-stored (default)
  aggregates: {
    Room: {
      persistence: "event-sourced",
      concurrency: { maxRetries: 3 },
      snapshots: { strategy: everyNEvents(50) },
    },
    Booking: {
      persistence: "event-sourced",
      concurrency: { maxRetries: 3 },
    },
    Inventory: {},
  },

  buses: () => ({
    commandBus: new InMemoryCommandBus(),
    eventBus: new RabbitMqEventBus({
      url: process.env.RABBITMQ_URL ?? "amqp://localhost:5672",
      exchangeName: "hotel.events",
      queuePrefix: "hotel",
    }),
    queryBus: new InMemoryQueryBus(),
  }),

  idempotency: () => new InMemoryIdempotencyStore(),
  metadataProvider: () => requestMetadataStorage.getStore() ?? {},
});

The persistenceAdapter provides all persistence stores. Per-aggregate wiring uses string shorthands ("event-sourced") instead of factory functions. Inventory defaults to state-stored from the adapter. Saga persistence, unit-of-work, and snapshot store are all inferred from the adapter automatically. Snapshots are only configured for Room since it is the longest-lived event-sourced aggregate.

The event bus uses @noddde/rabbitmq (RabbitMqEventBus) backed by a RabbitMQ container in docker-compose.yml. The Domain auto-calls connect() during wiring and close() on shutdown — no manual lifecycle management needed. Set EVENT_BUS=in-memory to fall back to EventEmitterEventBus for quick local dev without a broker. See Event Bus Adapters for the full adapter reference.

Unit of Work

The group booking endpoint demonstrates domain.withUnitOfWork(). All commands dispatched inside the callback share a single database transaction — either all commit or all roll back.

// infrastructure/http/routes/bookings.ts
fastify.post("/bookings/group", async (request, reply) => {
  const { guestId, rooms } = request.body;
  const bookingIds: string[] = [];

  await domain.withUnitOfWork(async () => {
    for (const room of rooms) {
      const bookingId = randomUUID();
      bookingIds.push(bookingId);

      // CreateBooking and ReserveRoom for each room in one transaction
      await domain.dispatchCommand({
        name: "CreateBooking",
        targetAggregateId: bookingId,
        payload: {
          guestId,
          roomType: room.roomType /* checkIn, checkOut, totalAmount */,
        },
      });
      await domain.dispatchCommand({
        name: "ReserveRoom",
        targetAggregateId: room.roomId,
        payload: { bookingId, guestId /* checkIn, checkOut */ },
      });
    }
  });

  return reply.status(201).send({ bookingIds });
});

If any command fails — for example, because a room is already reserved — the entire unit of work rolls back and no bookings are created. This gives group bookings all-or-nothing semantics at the database level.

See Also

On this page