Project Structure
Understanding the folder layout generated by the noddde CLI
When you run noddde new project <name> or noddde new domain <name>, the CLI generates a layered folder structure that separates your domain into event model, write model, read model, infrastructure, and wiring.
Full Project Layout
A project generated with noddde new project HotelBooking produces:
hotel-booking/
├── package.json # dependencies + scripts
├── tsconfig.json # TypeScript config
├── vitest.config.mts # test config
├── .gitignore
└── src/
├── main.ts # wireDomain — runtime entry point
├── infrastructure/
│ └── index.ts # domain infrastructure interface
├── domain/
│ ├── domain.ts # defineDomain — pure domain structure
│ ├── event-model/
│ │ ├── index.ts
│ │ └── hotel-booking-created.ts
│ ├── write-model/
│ │ ├── index.ts
│ │ └── aggregates/
│ │ └── hotel-booking/
│ │ ├── index.ts
│ │ ├── state.ts
│ │ ├── hotel-booking.ts
│ │ ├── commands/
│ │ └── command-handlers/
│ ├── read-model/
│ │ └── projections/
│ │ └── hotel-booking/
│ │ ├── index.ts
│ │ ├── hotel-booking.ts
│ │ ├── queries/
│ │ ├── query-handlers/
│ │ └── view-reducers/
│ └── process-model/ # ready for sagas
└── __tests__/
└── hotel-booking.test.ts # sample testLayer by Layer
Event Model
domain/event-model/
├── index.ts # barrel re-exporting all event payloads
└── hotel-booking-created.ts # HotelBookingCreatedPayload interfaceEvent payloads are defined as standalone interfaces — one file per event. This directory is the shared source of truth for event shapes across the domain. The aggregate that produces each event assembles them into a DefineEvents union.
Write Model
domain/write-model/
├── index.ts # barrel re-exporting aggregates
└── aggregates/
└── hotel-booking/
├── index.ts # barrel + DefineEvents/DefineCommands type unions
├── state.ts # HotelBookingState interface + initial state
├── hotel-booking.ts # defineAggregate + type bundle
├── commands/
│ ├── index.ts
│ └── create-hotel-booking.ts # command payload interface
└── command-handlers/
├── index.ts
└── handle-create-hotel-booking.ts # standalone handler functionState is defined in a separate state.ts file next to the aggregate definition. This keeps the state interface and initial values easy to find and import independently.
Each aggregate owns its type unions. The aggregate file imports event payloads from event-model/ and command payloads from commands/, then builds DefineEvents and DefineCommands types inline — the aggregate knows which events and commands it uses.
Command handlers are standalone exported functions. They are imported into the defineAggregate commands map. This keeps each handler testable in isolation and the aggregate definition concise:
// command-handlers/handle-create-hotel-booking.ts
export function handleCreateHotelBooking(command: { targetAggregateId: string }) {
return {
name: "HotelBookingCreated" as const,
payload: { id: command.targetAggregateId },
};
}
// hotel-booking.ts
import { handleCreateHotelBooking } from "./command-handlers/index.js";
export const HotelBooking = defineAggregate<HotelBookingDef>({
commands: {
CreateHotelBooking: handleCreateHotelBooking,
},
// ...
});Read Model
domain/read-model/
└── projections/
└── hotel-booking/
├── index.ts # barrel exports
├── hotel-booking.ts # defineProjection
├── queries/
│ ├── index.ts # View interface + DefineQueries
│ └── get-hotel-booking.ts # query payload interface
├── query-handlers/
│ ├── index.ts
│ └── handle-get-hotel-booking.ts # standalone query handler
└── view-reducers/
├── index.ts
└── on-hotel-booking-created.ts # standalone reduce functionProjections follow the same extraction pattern as aggregates. Query handlers and view reducers are standalone functions imported into the defineProjection queryHandlers and on maps:
// view-reducers/on-hotel-booking-created.ts
export function onHotelBookingCreated(event: { payload: { id: string } }): HotelBookingView {
return { id: event.payload.id };
}
// hotel-booking.ts (projection)
import { onHotelBookingCreated } from "./view-reducers/index.js";
import { handleGetHotelBooking } from "./query-handlers/index.js";
export const HotelBookingProjection = defineProjection<HotelBookingProjectionDef>({
on: {
HotelBookingCreated: {
id: (event) => event.payload.id,
reduce: onHotelBookingCreated,
},
},
queryHandlers: {
GetHotelBooking: handleGetHotelBooking,
},
});Process Model
domain/process-model/
└── booking-fulfillment/
├── index.ts # barrel exports
├── state.ts # BookingFulfillmentSagaState + initial state
├── saga.ts # defineSaga with on map, imports handlers
└── transition-handlers/
├── index.ts
└── on-start-event.ts # standalone transition handlerSagas follow the same extraction pattern. Transition handlers are standalone functions imported into the defineSaga on map. Each handler receives an event and the current saga state, and returns the new state plus optional commands to dispatch:
// transition-handlers/on-start-event.ts
export function onStartEvent(event: { payload: { id: string } }, state: BookingFulfillmentSagaState) {
return {
state: { ...state, status: "started" },
commands: {
name: "RequestPayment" as const,
targetAggregateId: event.payload.id,
payload: { ... },
},
};
}
// saga.ts
import { onStartEvent } from "./transition-handlers/index.js";
export const BookingFulfillmentSaga = defineSaga<BookingFulfillmentSagaDef>({
startedBy: ["OrderPlaced"],
on: {
OrderPlaced: {
id: (event) => event.payload.orderId,
handle: onStartEvent,
},
},
});The process-model/ directory is created empty when scaffolding a project or domain. Add sagas with noddde new saga <name>.
Domain Definition
domain/domain.tsThe defineDomain call captures the pure domain structure — aggregates, projections, and sagas — without any infrastructure or runtime concerns:
export const hotelBookingDomain = defineDomain<HotelBookingInfrastructure>({
writeModel: { aggregates: { HotelBooking } },
readModel: { projections: { HotelBooking: HotelBookingProjection } },
});This is a sync identity function. It returns the input with full type inference. See Domain Configuration for the complete API.
Infrastructure
infrastructure/
└── index.tsDefines the domain-wide infrastructure interface that gets passed to all handlers:
export interface HotelBookingInfrastructure extends Infrastructure {
// clock, logger, external services, etc.
}Main Entry Point
main.tsWires the domain to runtime infrastructure using wireDomain:
const domain = await wireDomain(hotelBookingDomain, {
infrastructure: () => ({ /* implementations */ }),
buses: () => ({
commandBus: new InMemoryCommandBus(),
eventBus: new EventEmitterEventBus(),
queryBus: new InMemoryQueryBus(),
}),
});Adding to the Domain
As your domain grows, use the standalone generators to add components:
# From the project root — the CLI knows where to place each component
noddde new aggregate Room
noddde new projection RoomAvailability
noddde new saga BookingFulfillmentThen wire them into domain.ts:
import { Room } from "./write-model/aggregates/room/index.js";
import { RoomAvailabilityProjection } from "./read-model/projections/room-availability/index.js";
import { BookingFulfillmentSaga } from "./process-model/booking-fulfillment/index.js";
export const hotelBookingDomain = defineDomain<HotelBookingInfrastructure>({
writeModel: { aggregates: { HotelBooking, Room } },
readModel: {
projections: {
HotelBooking: HotelBookingProjection,
RoomAvailability: RoomAvailabilityProjection,
},
},
processModel: {
sagas: { BookingFulfillment: BookingFulfillmentSaga },
},
});