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 runningDomaininstance.
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
| Field | Description |
|---|---|
persistenceAdapter | A PersistenceAdapter instance (e.g., DrizzleAdapter, PrismaAdapter, TypeORMAdapter). See Persistence Adapter. |
infrastructure | Factory for user-provided services. What handlers receive as the infrastructure parameter. |
aggregates | Aggregate runtime config -- global AggregateWiring or per-aggregate record. See below. |
projections | Per-projection view store wiring. See Projection Wiring. |
sagas | Saga runtime config. Defaults to adapter or in-memory if processModel has sagas. |
buses | Factory for CQRS buses. Receives resolved user infrastructure. |
unitOfWork | Factory for the UnitOfWorkFactory. |
idempotency | Factory for idempotency store (command dedup by commandId). |
outbox | Transactional outbox configuration. |
metadataProvider | Function called on every command dispatch for event metadata context. |
logger | Framework 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
persistencedefault to the adapter'sstateStoredPersistence(state-stored is the default -- event sourcing is opt-in) persistenceaccepts string shorthands:"event-sourced"or"state-stored", resolved from the adapterpersistencealso acceptsadapter.stateStored(table)for dedicated per-aggregate state tablessnapshots.storeis inferred from the adapter when omitted (butstrategyis still required)sagas.persistence,unitOfWork,idempotency, andoutboxare all inferred from the adapter when omittedadapter.init?.()is called at the start of domain initializationadapter.close?.()is called during domain shutdown (auto-discovered viaisCloseable())
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 aggregateconcurrency-- 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:
| Form | Example | Description |
|---|---|---|
| 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 config | adapter.stateStored(table) | A PersistenceConfiguration object used directly |
| Factory function | () => myPersistence | Existing pattern -- called to get the config |
Concurrency
The concurrency field accepts string shorthands or object form:
| Form | Example | Description |
|---|---|---|
| Omitted | Optimistic 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.viewStorefield is deprecated. Provide view stores viaDomainWiring.projectionsinstead. 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:
- Reject — New calls to
dispatchCommand(),dispatchQuery(), andwithUnitOfWork()immediately throw aDomainShutdownError. - Drain operations — The domain waits for all in-flight commands, queries, and their cascading saga reactions to complete.
- Drain outbox relay — If configured, the outbox relay stops polling and processes any remaining unpublished entries until the outbox is empty.
- Auto-close infrastructure — The domain scans all registered infrastructure components and calls
close()on any that implement theCloseableinterface.
Timeout
You can set a maximum duration for the drain phases:
await domain.shutdown({ timeoutMs: 10_000 }); // 10 secondsThe 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
- Infrastructure -- The infrastructure providers in detail
- Persistence -- Event-sourced vs. state-stored persistence
- Unit of Work -- Atomic command dispatch
- Process Managers -- Coordinating workflows across aggregates
Standalone Event Handlers
Lightweight, stateless handlers that react to domain events with simple side effects like notifications, logging, and external system updates.
Infrastructure
How noddde uses injectable infrastructure to keep domain logic pure, and how to configure providers, buses, and custom services.