noddde

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 test

Layer by Layer

Event Model

domain/event-model/
├── index.ts                        # barrel re-exporting all event payloads
└── hotel-booking-created.ts        # HotelBookingCreatedPayload interface

Event 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 function

State 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 function

Projections 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 handler

Sagas 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.ts

The 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.ts

Defines 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.ts

Wires 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 BookingFulfillment

Then 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 },
  },
});

On this page