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:

package.json
tsconfig.json
vitest.config.mts
.gitignore
main.ts
domain.ts
index.ts

Layer by Layer

Event Model

index.ts
hotel-booking-created.ts

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

index.ts
index.ts
state.ts
hotel-booking.ts

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.

Decide handlers and evolve handlers are standalone exported functions typed with InferDecideHandler and InferEvolveHandler. They are imported into the defineAggregate decide and evolve maps. This keeps each handler individually testable and the aggregate definition concise:

// deciders/decide-create-hotel-booking.ts
import type { InferDecideHandler } from "@noddde/core";
import type { HotelBookingDef } from "../hotel-booking.js";

export const decideCreateHotelBooking: InferDecideHandler<
  HotelBookingDef,
  "CreateHotelBooking"
> = (command, _state) => ({
  name: "HotelBookingCreated" as const,
  payload: { id: command.targetAggregateId },
});

// evolvers/evolve-hotel-booking-created.ts
import type { InferEvolveHandler } from "@noddde/core";
import type { HotelBookingDef } from "../hotel-booking.js";

export const evolveHotelBookingCreated: InferEvolveHandler<
  HotelBookingDef,
  "HotelBookingCreated"
> = (payload, state) => ({
  ...state,
  id: payload.id,
});

// hotel-booking.ts
export type HotelBookingDef = {
  /* ... */
};

export const HotelBooking = defineAggregate<HotelBookingDef>({
  decide: { CreateHotelBooking: decideCreateHotelBooking },
  evolve: { HotelBookingCreated: evolveHotelBookingCreated },
  // ...
});

Read Model

index.ts
hotel-booking.ts

Projections follow the same extraction pattern as aggregates. Query handlers are typed with InferProjectionQueryHandler and on-entries are the event reducers that build the view. Both are standalone functions imported into the defineProjection queryHandlers and on maps:

// on-entries/on-hotel-booking-created.ts
import type { InferProjectionEventHandler } from "@noddde/core";
import type { HotelBookingProjectionDef } from "../hotel-booking.js";

export const onHotelBookingCreated: InferProjectionEventHandler<
  HotelBookingProjectionDef,
  "HotelBookingCreated"
> = {
  id: (event) => event.payload.id,
  reduce: (event, _view) => ({ id: event.payload.id }),
};

// query-handlers/handle-get-hotel-booking.ts
import type { InferProjectionQueryHandler } from "@noddde/core";
import type { HotelBookingProjectionDef } from "../hotel-booking.js";

export const handleGetHotelBooking: InferProjectionQueryHandler<
  HotelBookingProjectionDef,
  "GetHotelBooking"
> = async (query, { views }) => (await views.load(query.payload.id)) ?? null;

// hotel-booking.ts (projection)
export type HotelBookingProjectionDef = {
  /* ... */
};

export const HotelBookingProjection =
  defineProjection<HotelBookingProjectionDef>({
    on: { HotelBookingCreated: onHotelBookingCreated },
    queryHandlers: { GetHotelBooking: handleGetHotelBooking },
  });

Process Model

index.ts
state.ts
saga.ts

Sagas follow the same extraction pattern. On-entries are standalone { id, handle } objects typed with InferSagaOnEntry, imported into the defineSaga on map. Each on-entry specifies how to extract the saga ID from the event and how to handle the transition:

// on-entries/on-order-placed.ts
import type { InferSagaOnEntry } from "@noddde/core";
import type { BookingFulfillmentDef } from "../saga.js";

export const onOrderPlaced: InferSagaOnEntry<
  BookingFulfillmentDef,
  "OrderPlaced"
> = {
  id: (event) => event.payload.orderId,
  handle: (event, state) => ({
    state: { ...state, status: "started" },
    commands: {
      name: "RequestPayment" as const,
      targetAggregateId: event.payload.orderId,
      payload: {
        /* ... */
      },
    },
  }),
};

// saga.ts
export type BookingFulfillmentDef = {
  /* ... */
};

export const BookingFulfillmentSaga = defineSaga<BookingFulfillmentDef>({
  startedBy: ["OrderPlaced"],
  on: { OrderPlaced: onOrderPlaced },
});

The process-model/ directory is created empty when scaffolding a project or domain. Add sagas with noddde new saga <name>.

Domain Definition

domain.ts

The defineDomain call captures the pure domain structure — aggregates, projections, and sagas — without any infrastructure or runtime concerns:

export const hotelBookingDomain = defineDomain({
  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

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({
  writeModel: { aggregates: { HotelBooking, Room } },
  readModel: {
    projections: {
      HotelBooking: HotelBookingProjection,
      RoomAvailability: RoomAvailabilityProjection,
    },
  },
  processModel: {
    sagas: { BookingFulfillment: BookingFulfillmentSaga },
  },
});

On this page