noddde

Domain Configuration

Using defineDomain and wireDomain to separate domain structure from runtime infrastructure, creating a running Domain instance.

The domain configuration is the entry point of every noddde application. It wires together your write model (aggregates), read model (projections), process model (sagas), and infrastructure into a single Domain instance that can dispatch commands and queries.

The API is split into two phases:

  • defineDomain -- Captures the pure domain structure (aggregates, projections, sagas, handlers) as a sync identity function. No infrastructure, no async, no side effects.
  • wireDomain -- Connects that definition to infrastructure (persistence, buses, concurrency, snapshots) and returns a running Domain instance.

This separation allows domain definitions to be shared, tested, and analyzed independently of runtime concerns.

You can scaffold a full project with noddde new project my-app, or add a domain layer to an existing project with noddde new domain my-domain. See CLI Reference for details.

Defining the Domain

defineDomain is a sync identity function -- it returns the input unchanged with full type inference. Consistent with defineAggregate, defineProjection, defineSaga.

import { defineDomain } from "@noddde/engine";

const bankingDomain = defineDomain({
  writeModel: {
    aggregates: { BankAccount },
    standaloneCommandHandlers: {
      ProcessDailySettlements: processSettlements,
    },
  },
  readModel: {
    projections: {
      BankAccount: BankAccountProjection,
      TransactionHistory: TransactionHistoryProjection,
    },
    standaloneQueryHandlers: {
      GetSystemStatus: getSystemStatus,
    },
  },
  processModel: {
    sagas: {
      BookingFulfillment: BookingFulfillmentSaga,
    },
  },
});

The DomainDefinition type captures three top-level sections:

  • writeModel -- What handles commands (aggregates and standalone command handlers)
  • readModel -- What handles events and queries (projections and standalone query handlers)
  • processModel -- What coordinates cross-aggregate workflows (sagas). Optional -- omit if you have no sagas.

The TInfrastructure generic is a type parameter only -- it flows through handler signatures for type safety but no infrastructure value is present in the definition.

Wiring the Domain

wireDomain accepts a DomainDefinition and an optional DomainWiring, resolves all factories, creates a Domain instance, and returns it. The wiring parameter can be omitted entirely for a zero-config hello world:

import { wireDomain } from "@noddde/engine";

// Hello world -- everything defaults to in-memory
const domain = await wireDomain(bankingDomain);

When you're ready for production, provide explicit wiring:

const domain = await wireDomain(bankingDomain, {
  // User-provided services (what handlers receive as `infrastructure`)
  infrastructure: () => ({
    bankAccountViewRepository: new InMemoryBankAccountViewRepository(),
  }),

  // Aggregate runtime -- global or per-aggregate
  aggregates: {
    persistence: () => new InMemoryEventSourcedAggregatePersistence(),
    concurrency: { maxRetries: 3 },
  },

  // CQRS buses
  buses: () => ({
    commandBus: new InMemoryCommandBus(),
    eventBus: new EventEmitterEventBus(),
    queryBus: new InMemoryQueryBus(),
  }),
});

In-Memory Default Warnings

When infrastructure components fall back to in-memory defaults, the framework logs startup warnings via the configured logger. In development (TTY), you'll see colored output:

2026-03-28T12:00:00.000Z  WARN 12345 --- [noddde:domain] Using in-memory aggregate persistence. This is not suitable for production.
2026-03-28T12:00:00.000Z  WARN 12345 --- [noddde:domain] Using in-memory CQRS buses. This is not suitable for production.
2026-03-28T12:00:00.000Z  WARN 12345 --- [noddde:domain] Using in-memory saga persistence. This is not suitable for production.

In production or non-TTY environments, the same messages appear as NDJSON:

{
  "timestamp": "2026-03-28T12:00:00.000Z",
  "level": "warn",
  "namespace": "noddde:domain",
  "message": "Using in-memory aggregate persistence. This is not suitable for production."
}

These warnings only appear when the framework itself supplies the default -- not when you explicitly provide a factory (even if it happens to return an in-memory implementation). Optional components like snapshots, idempotency, and outbox do not trigger warnings when omitted, since omitting them is a valid production choice.

DomainWiring Fields

FieldDescription
persistenceAdapterA PersistenceAdapter instance (e.g., DrizzleAdapter, PrismaAdapter, TypeORMAdapter). See Persistence Adapter.
infrastructureFactory for user-provided services. What handlers receive as the infrastructure parameter.
aggregatesAggregate runtime config -- global AggregateWiring or per-aggregate record. See below.
projectionsPer-projection view store wiring. See Projection Wiring.
sagasSaga runtime config. Defaults to adapter or in-memory if processModel has sagas.
busesFactory for CQRS buses. Receives resolved user infrastructure.
unitOfWorkFactory for the UnitOfWorkFactory.
idempotencyFactory for idempotency store (command dedup by commandId).
outboxTransactional outbox configuration.
metadataProviderFunction called on every command dispatch for event metadata context.
loggerFramework logger instance. Defaults to NodddeLogger at 'warn' level.

All fields are optional. Both wireDomain(definition) and wireDomain(definition, {}) use in-memory defaults for everything.

Persistence Adapter

The persistenceAdapter field accepts a PersistenceAdapter instance. When provided, the engine resolves aggregate persistence, saga persistence, unit-of-work, snapshots, outbox, idempotency, and locking from the adapter when not explicitly wired. Explicit wiring always takes precedence over adapter defaults.

import { DrizzleAdapter } from "@noddde/drizzle";

const adapter = new DrizzleAdapter(db);

const domain = await wireDomain(bankingDomain, {
  persistenceAdapter: adapter,
  infrastructure: () => ({
    /* ... */
  }),
  aggregates: {
    BankAccount: {
      persistence: "event-sourced",
      snapshots: { strategy: everyNEvents(100) },
    },
    Ledger: {}, // defaults to state-stored from adapter
  },
});

When an adapter is present:

  • Aggregates that omit persistence default to the adapter's stateStoredPersistence (state-stored is the default -- event sourcing is opt-in)
  • persistence accepts string shorthands: "event-sourced" or "state-stored", resolved from the adapter
  • persistence also accepts adapter.stateStored(table) for dedicated per-aggregate state tables
  • snapshots.store is inferred from the adapter when omitted (but strategy is still required)
  • sagas.persistence, unitOfWork, idempotency, and outbox are all inferred from the adapter when omitted
  • adapter.init?.() is called at the start of domain initialization
  • adapter.close?.() is called during domain shutdown (auto-discovered via isCloseable())

See Persistence Adapters for adapter-specific setup.

Logging

The framework uses a Logger interface for all internal logging. By default, a NodddeLogger at 'warn' level is used -- it auto-detects the environment (colored output in dev, NDJSON in production). The logger is also automatically available as infrastructure.logger in all handlers, and passed to the infrastructure factory so your custom services can use it too.

See Logging for full details on output formats, log levels, using the logger in handlers and custom infrastructure, and bringing your own logger.

Aggregate Wiring

The aggregates field accepts either a global config (applies to all aggregates) or a per-aggregate record:

// Global: all aggregates share the same config
aggregates: {
  persistence: () => adapter.eventSourcedPersistence,
  concurrency: { maxRetries: 3 },
},

// Per-aggregate: each aggregate configured independently
aggregates: {
  Order: {
    persistence: "event-sourced",
    concurrency: { maxRetries: 5 },
    snapshots: { strategy: everyNEvents(50) },
  },
  Inventory: {}, // defaults to state-stored from adapter
},

Each AggregateWiring groups three concerns:

  • persistence -- The persistence strategy for this aggregate
  • concurrency -- Optimistic (with retries) or pessimistic (with locker)
  • snapshots -- Snapshot strategy for event-sourced aggregates (store inferred from adapter)

Persistence

The persistence field accepts multiple forms:

FormExampleDescription
Omitted{}Defaults to adapter.stateStoredPersistence (when adapter present) or in-memory
String shorthand"event-sourced" or "state-stored"Resolved from the adapter. Requires persistenceAdapter
Direct configadapter.stateStored(table)A PersistenceConfiguration object used directly
Factory function() => myPersistenceExisting pattern -- called to get the config

Concurrency

The concurrency field accepts string shorthands or object form:

FormExampleDescription
OmittedOptimistic with 0 retries (default)
"optimistic"concurrency: "optimistic"Same as omitting
"pessimistic"concurrency: "pessimistic"Auto-resolves locker from adapter.aggregateLocker
Object (optimistic){ maxRetries: 5 }Optimistic with custom retries
Object (pessimistic){ strategy: "pessimistic", locker?: ... }Pessimistic; locker auto-resolved from adapter when omitted

Projection Wiring

View stores are configured per-projection in the wiring, separating runtime concerns from the projection definition:

projections: {
  BankAccount: {
    viewStore: (infra) => infra.bankAccountViewRepository,
  },
  TransactionHistory: {
    viewStore: (infra) => new InMemoryViewStore(),
  },
},

The viewStore factory receives the resolved user infrastructure, allowing you to pull view stores from your infrastructure services.

Note: The Projection.viewStore field is deprecated. Provide view stores via DomainWiring.projections instead. When both are set, the wiring version takes priority.

Write Model

The writeModel section defines how your application processes commands.

Registering Aggregates

The aggregates map associates names with aggregate definitions:

import { BankAccount } from "./aggregate";

writeModel: {
  aggregates: {
    BankAccount,
  },
},

Each key becomes the aggregate name used for command routing, persistence (identifying the event or state stream), and logging. Using shorthand property names ({ BankAccount } instead of { BankAccount: BankAccount }) keeps the configuration concise and the aggregate name consistent with the variable name.

A domain can register any number of aggregate types. Each aggregate type handles its own set of commands, and the framework routes commands based on the aggregate configuration.

Standalone Command Handlers

Some commands do not target a specific aggregate instance. These are handled by standalone command handlers:

import { StandaloneCommandHandler } from "@noddde/core";

const processSettlements: StandaloneCommandHandler<
  BankingInfrastructure,
  ProcessDailySettlementsCommand
> = async (command, infrastructure) => {
  const { commandBus } = infrastructure;

  for (const accountId of command.payload.accountIds) {
    await commandBus.dispatch({
      name: "SettleAccount",
      targetAggregateId: accountId,
    });
  }
};

Register them in standaloneCommandHandlers:

writeModel: {
  aggregates: { BankAccount },
  standaloneCommandHandlers: {
    ProcessDailySettlements: processSettlements,
  },
},

Standalone command handlers receive the merged infrastructure (TInfrastructure & CQRSInfrastructure), giving them access to all three buses. This makes them suitable for orchestration tasks such as batch processing, system commands, and scheduled operations.

Read Model

The readModel section defines how your application builds views and serves queries.

Registering Projections

import { BankAccountProjection } from "./projection";
import { TransactionHistoryProjection } from "./transaction-history";

readModel: {
  projections: {
    // With view store — enables auto-persistence
    BankAccount: {
      projection: BankAccountProjection,
      viewStore: (infra) => infra.bankAccountViewStore,
    },
    // Without view store — query-only or manually persisted
    TransactionHistory: TransactionHistoryProjection,
  },
},

Each projection entry can be a bare projection definition or an object wrapping the projection with a viewStore factory. When a viewStore is provided, the engine auto-persists views using the id and reduce functions in the projection's on map. See View Persistence for details.

Each projection receives events from the EventBus and updates its view accordingly. Multiple projections can consume the same events to build different views. The key in the map is the projection name -- used for identification and logging. It does not need to match any aggregate name.

Standalone Query Handlers

For queries that do not belong to any specific projection, register them as standalone query handlers:

import { QueryHandler } from "@noddde/core";

const getSystemStatus: QueryHandler<
  BankingInfrastructure,
  GetSystemStatusQuery
> = async (_query, infrastructure) => {
  return {
    activeAccounts: 42,
    uptime: process.uptime(),
  };
};

readModel: {
  projections: { BankAccount: BankAccountProjection },
  standaloneQueryHandlers: {
    GetSystemStatus: getSystemStatus,
  },
},

Standalone query handlers are useful for cross-projection queries, system health checks, and ad-hoc queries that do not have a dedicated projection.

Process Model

The processModel section registers sagas and standalone event handlers for event-driven workflows and side effects:

import { BookingFulfillmentSaga } from "./process-model/booking-fulfillment";

processModel: {
  sagas: {
    BookingFulfillment: BookingFulfillmentSaga,
  },
  standaloneEventHandlers: {
    BookingConfirmed: (event, infrastructure) => {
      infrastructure.emailService.sendConfirmation(event.payload.guestEmail);
    },
  },
},

Both sagas and standaloneEventHandlers are optional — include only what you need.

Standalone Event Handlers

Standalone event handlers are lightweight, stateless functions that react to domain events with simple side effects — sending emails, logging audit trails, notifying external systems. Unlike sagas, they don't maintain state, don't dispatch commands, and don't need persistence.

standaloneEventHandlers: {
  OrderPlaced: async (event, infrastructure) => {
    await infrastructure.analytics.track("order_placed", event.payload);
  },
  PaymentReceived: (event, infrastructure) => {
    infrastructure.logger.info(`Payment received: ${event.payload.amount}`);
  },
},

Each handler receives the full event object (including metadata) and the domain infrastructure. Use a saga instead when you need state tracking, command dispatch, or multi-step coordination.

The process model is optional. Omit it entirely if your domain does not need cross-aggregate coordination or event-driven side effects.

The Domain Class

The Domain class returned by wireDomain exposes the main capabilities of your application.

Dispatching Commands

const aggregateId = await domain.dispatchCommand({
  name: "CreateBankAccount",
  targetAggregateId: "acc-123",
});

dispatchCommand is the primary way to interact with the domain. It is strongly typed: only commands from registered aggregates and standalone command handlers are accepted. TypeScript provides autocomplete for command names and infers payload types via discriminated union narrowing. The return type is the aggregate's targetAggregateId type for aggregate commands, or void for standalone commands.

Dispatching Queries

const account = await domain.dispatchQuery({
  name: "GetBankAccountById",
  payload: { id: "acc-123" },
});

dispatchQuery is also strongly typed: only queries from registered projections and standalone query handlers are accepted. The result type is inferred from the query's phantom type parameter.

Accessing Infrastructure

const { commandBus, eventBus, queryBus } = domain.infrastructure;

The infrastructure getter returns the merged custom infrastructure and CQRS infrastructure.

Reading Projection Views

Projection views are persisted via ViewStore. To read a projection's view, query through the query bus or access the view store directly from your infrastructure:

// Via query bus
const view = await domain.infrastructure.queryBus.dispatch({
  name: "GetOrderSummary",
  payload: { orderId: "order-1" },
});

// Or via the view store on your infrastructure
const view = await domain.infrastructure.orderSummaryViewStore.load("order-1");

See View Persistence for details on configuring projections with ViewStore.

Event Metadata

Every event dispatched through the domain is automatically enriched with an EventMetadata envelope containing audit, tracing, and sequencing information. Decide handlers return bare { name, payload } objects -- the engine adds metadata before persistence and publication.

What Metadata Contains

interface EventMetadata {
  eventId: string; // UUID v7 (time-ordered)
  timestamp: string; // ISO 8601
  correlationId: string; // Shared across a causal chain
  causationId: string; // The command or event that caused this
  userId?: ID; // Who initiated the action (string, number, or bigint)
  aggregateName?: string; // Which aggregate produced this event
  aggregateId?: ID; // Which aggregate instance
  sequenceNumber?: number; // Position in the event stream
}

Providing Context with metadataProvider

The metadataProvider is a function called on every command dispatch. Use it to inject per-request context (e.g., the authenticated user ID or a trace ID from HTTP middleware):

const domain = await wireDomain(bankingDomain, {
  // ...other wiring
  metadataProvider: () => ({
    userId: getCurrentUserId(), // from your auth middleware
    correlationId: getRequestTraceId(), // from your HTTP context
  }),
});

Values from the provider are merged into every event's metadata. Fields you omit are auto-generated (e.g., correlationId defaults to a new UUID v7).

Per-Request Override with withMetadataContext

For one-off overrides (admin actions, migration scripts, manual fixes), use withMetadataContext as an escape hatch:

await domain.withMetadataContext(
  { userId: "admin", correlationId: "manual-fix-123" },
  () => domain.dispatchCommand(fixCommand),
);

Values from withMetadataContext take precedence over the metadataProvider.

Correlation Propagation in Sagas

When a saga dispatches commands in response to an event, the engine automatically propagates the triggering event's correlationId to all downstream events. This creates a traceable chain across aggregates without any manual wiring.

Complete Example

Here is a full domain configuration for a banking application using defineDomain + wireDomain:

import {
  defineDomain,
  wireDomain,
  EventEmitterEventBus,
  InMemoryCommandBus,
  InMemoryEventSourcedAggregatePersistence,
  InMemoryQueryBus,
  InMemoryViewStore,
} from "@noddde/engine";
import { BankAccount } from "./aggregate";
import { BankAccountProjection } from "./projection";
import { TransactionHistoryProjection } from "./transaction-history";

interface BankingInfrastructure {
  logger: Logger;
  bankAccountViewRepository: BankAccountViewRepository;
  transactionViewRepository: TransactionViewRepository;
}

// Step 1: Define the domain structure (pure, sync)
const bankingDomain = defineDomain({
  writeModel: {
    aggregates: { BankAccount },
  },
  readModel: {
    projections: {
      // With view store — engine auto-persists views on each event
      BankAccount: {
        projection: BankAccountProjection,
        viewStore: (infra) => infra.bankAccountViewStore,
      },
      // Without view store — query-only or manually persisted
      TransactionHistory: TransactionHistoryProjection,
    },
  },
});

// Step 2: Wire with infrastructure (async)
const domain = await wireDomain(bankingDomain, {
  infrastructure: () => ({
    bankAccountViewRepository: new InMemoryBankAccountViewRepository(),
    transactionViewRepository: new InMemoryTransactionViewRepository(),
  }),
  aggregates: {
    persistence: () => new InMemoryEventSourcedAggregatePersistence(),
  },
  projections: {
    BankAccount: {
      viewStore: (infra) => infra.bankAccountViewRepository,
    },
    TransactionHistory: {
      viewStore: (infra) => infra.transactionViewRepository,
    },
  },
  buses: () => ({
    commandBus: new InMemoryCommandBus(),
    eventBus: new EventEmitterEventBus(),
    queryBus: new InMemoryQueryBus(),
  }),
});

// Dispatch a command
await domain.dispatchCommand({
  name: "CreateBankAccount",
  targetAggregateId: "acc-001",
  payload: { owner: "Alice" },
});

// Dispatch a query
const account = await domain.dispatchQuery({
  name: "GetBankAccountById",
  payload: { id: "acc-001" },
});

Graceful Shutdown

When your application needs to stop — whether for a deployment, scaling event, or process signal — call domain.shutdown() to safely wind down the domain:

const domain = await wireDomain(bankingDomain, {
  /* wiring */
});

// Later, on SIGTERM or application teardown:
await domain.shutdown();

What Happens During Shutdown

Shutdown proceeds through four sequential phases:

  1. Reject — New calls to dispatchCommand(), dispatchQuery(), and withUnitOfWork() immediately throw a DomainShutdownError.
  2. Drain operations — The domain waits for all in-flight commands, queries, and their cascading saga reactions to complete.
  3. Drain outbox relay — If configured, the outbox relay stops polling and processes any remaining unpublished entries until the outbox is empty.
  4. Auto-close infrastructure — The domain scans all registered infrastructure components and calls close() on any that implement the Closeable interface.

Timeout

You can set a maximum duration for the drain phases:

await domain.shutdown({ timeoutMs: 10_000 }); // 10 seconds

The default timeout is 30 seconds. If in-flight operations do not complete within the timeout, shutdown proceeds to the next phase. The auto-close phase always runs regardless of timeout.

DomainShutdownError

Commands and queries dispatched after shutdown has started receive a DomainShutdownError:

import { DomainShutdownError } from "@noddde/engine";

try {
  await domain.dispatchCommand(command);
} catch (error) {
  if (error instanceof DomainShutdownError) {
    // Domain is shutting down — reject the request upstream
  }
}

Idempotent

Calling shutdown() multiple times returns the same promise. The shutdown sequence only runs once.

Making Infrastructure Closeable

If your custom infrastructure holds resources (database connections, file handles, network sockets), implement the Closeable interface so the domain releases them automatically:

import type { Closeable } from "@noddde/core";

class MyDatabasePool implements Closeable {
  async close(): Promise<void> {
    await this.pool.end();
  }
}

The domain auto-detects Closeable implementations across all infrastructure you provide — custom infrastructure values, buses, persistence stores, and ORM adapters. No additional configuration is needed.

close() must be idempotent: calling it more than once should have no additional effect.

Next Steps

On this page