noddde

Domain Configuration

Using configureDomain to wire aggregates, projections, sagas, and infrastructure into a running domain.

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 configureDomain Function

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

const domain = await configureDomain<BankingInfrastructure>({
  writeModel: {
    aggregates: {
      /* ... */
    },
    standaloneCommandHandlers: {
      /* ... */
    },
  },
  readModel: {
    projections: {
      /* ... */
    },
    standaloneQueryHandlers: {
      /* ... */
    },
  },
  processModel: {
    sagas: {
      /* ... */
    },
  },
  infrastructure: {
    aggregatePersistence: () => {
      /* ... */
    },
    sagaPersistence: () => {
      /* ... */
    },
    provideInfrastructure: () => {
      /* ... */
    },
    cqrsInfrastructure: () => {
      /* ... */
    },
  },
});

configureDomain is an async factory function. It initializes infrastructure using the provider factories, registers all handlers on the appropriate buses, and returns a ready-to-use Domain instance.

The DomainConfiguration Type

type DomainConfiguration<TInfrastructure extends Infrastructure> = {
  writeModel: {
    aggregates: Record<string, Aggregate<any>>;
    standaloneCommandHandlers?: Record<string, StandaloneCommandHandler<...>>;
  };
  readModel: {
    projections: Record<string, Projection<any>>;
    standaloneQueryHandlers?: Record<string, QueryHandler<...>>;
  };
  processModel?: {
    sagas: Record<string, Saga<any, any>>;
  };
  infrastructure: {
    aggregatePersistence?: () => PersistenceConfiguration | Promise<PersistenceConfiguration>;
    aggregateConcurrency?:
      | { strategy?: "optimistic"; maxRetries?: number }
      | { strategy: "pessimistic"; locker: AggregateLocker; lockTimeoutMs?: number };
    snapshotStore?: () => SnapshotStore | Promise<SnapshotStore>;
    snapshotStrategy?: SnapshotStrategy;
    idempotencyStore?: () => IdempotencyStore | Promise<IdempotencyStore>;
    sagaPersistence?: () => SagaPersistence | Promise<SagaPersistence>;
    provideInfrastructure?: () => TInfrastructure | Promise<TInfrastructure>;
    cqrsInfrastructure?: (infra: TInfrastructure) => CQRSInfrastructure | Promise<CQRSInfrastructure>;
  };
  metadataProvider?: () => MetadataContext;
};

Five top-level sections, each with a distinct responsibility:

  • 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.
  • infrastructure -- How things are wired (persistence, domain infrastructure, CQRS buses). See the Infrastructure page for details.
  • metadataProvider -- Optional function called on every command dispatch to provide event metadata context (userId, correlationId, etc.).

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: {
    BankAccount: BankAccountProjection,
    TransactionHistory: TransactionHistoryProjection,
  },
},

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 that coordinate workflows across multiple aggregates:

import { OrderFulfillmentSaga } from "./saga/order-fulfillment";

processModel: {
  sagas: {
    OrderFulfillment: OrderFulfillmentSaga,
  },
},

The process model is optional. Omit it entirely if your domain does not need cross-aggregate coordination.

Infrastructure

The infrastructure section provides factory functions that create the runtime dependencies for your domain. All providers are optional and all are factory functions -- not raw values. This enables lazy initialization, async setup (database connections, config loading), and clean test isolation.

The providers are:

  • provideInfrastructure -- Your custom domain infrastructure (loggers, repositories, clients)
  • cqrsInfrastructure -- The three CQRS buses (CommandBus, EventBus, QueryBus)
  • aggregatePersistence -- The persistence strategy for aggregate state. See Persistence.
  • aggregateConcurrency -- Optimistic or pessimistic concurrency strategy. See Concurrency Control.
  • snapshotStore / snapshotStrategy -- Snapshot caching for event-sourced aggregates. See State Snapshotting.
  • idempotencyStore -- Duplicate command detection. See Idempotent Commands.
  • unitOfWorkFactory -- Atomic command dispatch boundaries. See Unit of Work.

For a full treatment of each provider, how they are initialized, and how to swap them for testing, see the Infrastructure page.

The Domain Class

The Domain class returned by configureDomain 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 routes the command to the correct aggregate, executes the handler, persists resulting events, and publishes them. The return type is inferred from the command's targetAggregateId type.

Dispatching Queries

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

dispatchQuery routes the query to its registered handler and returns the typed result.

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. Command 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 configureDomain<MyInfrastructure>({
  writeModel: { aggregates: { BankAccount } },
  readModel: { projections: {} },
  infrastructure: {
    /* ... */
  },
  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, showing all sections together:

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

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

const domain = await configureDomain<BankingInfrastructure>({
  writeModel: {
    aggregates: {
      BankAccount,
    },
  },
  readModel: {
    projections: {
      BankAccount: BankAccountProjection,
      TransactionHistory: TransactionHistoryProjection,
    },
  },
  infrastructure: {
    aggregatePersistence: () => new InMemoryEventSourcedAggregatePersistence(),
    provideInfrastructure: () => ({
      logger: new ConsoleLogger(),
      bankAccountViewRepository: new InMemoryBankAccountViewRepository(),
      transactionViewRepository: new InMemoryTransactionViewRepository(),
    }),
    cqrsInfrastructure: () => ({
      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" },
});

Next Steps

On this page