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:
| Aggregate | Responsibility | Persistence |
|---|---|---|
Booking | Payment state machine for one booking | Event-sourced |
Room | Physical room lifecycle (reserve → occupy) | Event-sourced |
Inventory | Aggregate availability counts by type | State-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
onPaymentCompleteddispatches both commands before the saga persists its updated state.ConfirmBookingandReserveRoomtarget different aggregates. - Conditional compensation: the
onBookingCancelledhandler checksstate.status === "confirmed"before issuing a refund. If payment never completed, the command isundefinedand 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:
RoomAvailabilityProjectionis 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.GuestHistoryProjectionandRevenueProjectionare also registered projections but useInMemoryViewStore— 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
- Sagas — how saga state is persisted and how the engine correlates events to saga instances
- Why Sagas Return Commands — the design rationale for command-returning sagas instead of side-effectful handlers
- Why Two Persistence Strategies — when to choose event-sourced vs state-stored
- Queries — standalone query handlers and how they differ from projection-backed queries
- Persistence Adapters — the
@noddde/drizzleadapter used in this sample - The Clock Pattern — deterministic time in decide handlers
- Example: Auction Domain — a simpler single-aggregate example with rejection events and the Clock pattern