noddde

Infrastructure

How noddde uses injectable infrastructure to keep domain logic pure, and how to configure providers, buses, and custom services.

Infrastructure in noddde refers to everything outside your domain logic -- databases, loggers, HTTP clients, clocks, view repositories, and any external service your handlers need. The framework enforces a strict boundary: domain logic depends on interfaces, and concrete implementations are injected at wiring time.

With the defineDomain + wireDomain API, infrastructure is cleanly separated from domain structure. User-provided services (what handlers receive) are distinct from framework plumbing (persistence, buses, concurrency). See Domain Configuration for the full API.

The Core Principle

A decide handler that decides whether a transaction is authorized should not know whether it is logging to the console or to CloudWatch. It should not know whether its state came from PostgreSQL or an in-memory store.

noddde enforces this by injecting infrastructure as a function parameter, never as a direct import:

decide: {
  AuthorizeTransaction: (command, state, { logger }) => {
    logger.info(`Processing transaction for ${command.payload.merchant}`);
    // Business logic here -- no knowledge of concrete logger
  },
},

The handler depends on the Logger interface, not a concrete SystemClock. This separation makes the entire framework testable without mocks for infrastructure internals.

The Infrastructure Type

The base Infrastructure type is deliberately empty:

type Infrastructure = {};

You extend it with whatever your domain needs:

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

This type becomes the infrastructure member of your AggregateTypes bundle. Each component declares only what it needs, and wireDomain computes the intersection at compile time.

Scoped Infrastructure: One Interface Per Component

The recommended pattern is to declare a narrow infrastructure interface per component rather than a single god-interface for the entire domain:

// Auction aggregate only needs a clock
interface AuctionInfrastructure {
  clock: Clock;
}

// Notification projection needs an email service
interface NotificationInfrastructure {
  emailService: EmailService;
}

// Payment saga needs both clock and payment gateway
interface PaymentInfrastructure {
  clock: Clock;
  paymentGateway: PaymentGateway;
}

When you wire the domain, wireDomain computes the intersection of all these types. The infrastructure factory must return an object satisfying all of them:

const domain = await wireDomain(definition, {
  // Compiler requires: { clock: Clock, emailService: EmailService, paymentGateway: PaymentGateway }
  infrastructure: () => ({
    clock: new SystemClock(),
    emailService: new SmtpEmailService(),
    paymentGateway: new StripePaymentGateway(),
  }),
});

If you forget a field, the compiler tells you exactly what's missing. If you add a new saga that needs smsService: SmsService, the compiler immediately flags wireDomain -- no runtime surprises.

Components that share the same dependency (like clock above) simply declare it independently. The intersection merges identical fields cleanly.

Defining Interfaces with Swappable Implementations

Each member of your infrastructure type should be an interface with concrete implementations:

interface Clock {
  now(): Date;
}

// Production
class SystemClock implements Clock {
  now(): Date {
    return new Date();
  }
}

// Test
class FixedClock implements Clock {
  constructor(private readonly date: Date) {}
  now(): Date {
    return this.date;
  }
}

This pattern -- interface plus swappable implementations -- is what makes noddde handlers testable without mocking frameworks.

Where Infrastructure Is Injected

Infrastructure appears as a parameter in three handler types:

Handler TypeParameter PositionReceives
Aggregate decide handlersThird parameterTInfrastructure
Projection event handlersSecond parameterTInfrastructure
Query handlersSecond parameterTInfrastructure
Standalone command handlersSecond parameterTInfrastructure & CQRSInfrastructure
Saga handlersThird parameterTInfrastructure & CQRSInfrastructure

Evolve handlers deliberately do not receive infrastructure. They are pure, synchronous functions for deterministic event replay -- no side effects allowed.

CQRSInfrastructure

In addition to your custom infrastructure, noddde provides a built-in CQRSInfrastructure type:

interface CQRSInfrastructure {
  commandBus: CommandBus;
  eventBus: EventBus;
  queryBus: QueryBus;
}

These three buses are the communication backbone of the CQRS architecture:

  • CommandBus -- Routes commands to aggregates and standalone command handlers
  • EventBus -- Publishes events to projections, sagas, and other subscribers
  • QueryBus -- Routes queries to query handlers with type-safe return values

Standalone command handlers and saga handlers receive the merged type TInfrastructure & CQRSInfrastructure, giving them access to both your custom services and the CQRS buses. This is what allows them to orchestrate across aggregates.

The Infrastructure Providers

With wireDomain, infrastructure is provided via the DomainWiring object. The key distinction is that user-provided services (what handlers receive) are separated from framework plumbing (persistence, buses, concurrency).

User Infrastructure

The infrastructure factory provides your custom domain services -- repositories, clients, clocks:

const domain = await wireDomain(bankingDomain, {
  infrastructure: () => ({
    clock: new SystemClock(),
    bankAccountViewRepository: new InMemoryBankAccountViewRepository(),
    transactionViewRepository: new InMemoryTransactionViewRepository(),
  }),
  // ...framework plumbing below
});

The infrastructure type is inferred automatically from your aggregates, projections, and sagas. You don't need to specify it explicitly -- wireDomain computes the intersection of all infrastructure types declared across your domain components and requires the factory to return exactly that type. TypeScript reports an error if you forget a member or return the wrong type.

Buses

The buses factory provides the three CQRS buses. It receives the resolved user infrastructure as a parameter:

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

Aggregate Wiring

The aggregates field provides persistence, concurrency, and snapshots -- either globally or per-aggregate:

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

// Per-aggregate: each aggregate configured independently
aggregates: {
  Order: {
    persistence: () => drizzle.eventSourcedPersistence,
    concurrency: { maxRetries: 5 },
    snapshots: { store: () => drizzle.snapshotStore, strategy: everyNEvents(50) },
  },
  Inventory: {
    persistence: () => drizzle.stateStoredPersistence,
  },
},

See Persistence for details on persistence strategies and Concurrency Control for concurrency options.

Projection Wiring

View stores are provided per-projection, separating runtime concerns from projection definitions:

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

Other Wiring Fields

unitOfWork: () => () => new PostgresUnitOfWork(pool),
idempotency: () => new InMemoryIdempotencyStore(),
sagas: { persistence: () => new InMemorySagaPersistence() },

Why Factory Functions

All providers are factory functions (not raw values). This is intentional:

  • Lazy initialization -- Resources are created when needed, not at import time
  • Async setup -- Database connections, service discovery, config loading
  • Clean isolation -- Each test can create fresh infrastructure
infrastructure: async () => {
  const dbConnection = await connectToDatabase();
  return {
    bankAccountRepo: new PostgresBankAccountRepository(dbConnection),
  };
},
buses: async () => ({
  commandBus: await createRabbitMQCommandBus(),
  eventBus: await createKafkaEventBus(),
  queryBus: new InMemoryQueryBus(),
}),

Provider Execution Order

When wireDomain is called:

  1. infrastructure() is called first to create user-provided services
  2. buses(userInfra) is called with the resolved user infrastructure
  3. aggregates is resolved: global config applied to all aggregates, or per-aggregate configs resolved individually
  4. projections view stores are resolved per-projection
  5. sagas.persistence() is called if sagas are configured
  6. idempotency(), unitOfWork(), outbox.store() are resolved if configured
  7. The Domain instance is created with all infrastructure merged

The resulting domain.infrastructure property is TInfrastructure & CQRSInfrastructure -- your custom infrastructure merged with the CQRS buses.

Custom Bus Implementations

The built-in buses (InMemoryCommandBus, EventEmitterEventBus, InMemoryQueryBus) are suitable for single-process applications. For production, you can implement custom buses backed by message brokers.

Logging Decorator Pattern

A common pattern is to wrap a bus with logging:

class LoggingCommandBus implements CommandBus {
  constructor(
    private readonly inner: CommandBus,
    private readonly logger: Logger,
  ) {}

  async dispatch(command: Command): Promise<void> {
    this.logger.info(`Dispatching command: ${command.name}`);
    await this.inner.dispatch(command);
    this.logger.info(`Command dispatched: ${command.name}`);
  }
}

// In the wiring:
buses: (infra) => ({
  commandBus: new LoggingCommandBus(new InMemoryCommandBus(), infra.logger),
  eventBus: new EventEmitterEventBus(),
  queryBus: new InMemoryQueryBus(),
}),

Notice how buses receives the custom infrastructure as a parameter. This allows the logging bus to use the same logger that decide handlers use.

Testing with Different Infrastructure

The defineDomain + wireDomain split makes it especially easy to swap infrastructure for testing -- the domain definition is shared, only the wiring differs:

// Shared domain definition
const bankingDomain = defineDomain({
  writeModel: { aggregates: { BankAccount } },
  readModel: { projections: { BankAccount: BankAccountProjection } },
});

// Production wiring
const prodDomain = await wireDomain(bankingDomain, {
  infrastructure: () => createProductionInfrastructure(),
  aggregates: { persistence: () => new PostgresEventStore() },
  buses: () => createProductionBuses(),
});

// Test wiring
const testDomain = await wireDomain(bankingDomain, {
  logger: new NoopLogger(), // Suppress framework logging in tests
  infrastructure: () => ({
    clock: new FixedClock(new Date("2026-01-01")),
    bankAccountViewRepository: new InMemoryBankAccountViewRepository(),
    transactionViewRepository: new InMemoryTransactionViewRepository(),
  }),
  aggregates: {
    persistence: () => new InMemoryEventSourcedAggregatePersistence(),
  },
  buses: () => ({
    commandBus: new InMemoryCommandBus(),
    eventBus: new EventEmitterEventBus(),
    queryBus: new InMemoryQueryBus(),
  }),
});

Same domain definition, different wiring. The domain logic does not change -- only the infrastructure factories differ between environments.

Accessing Buses at Runtime

After configuration, buses are available on domain.infrastructure:

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

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

Resource Cleanup with Closeable

If your custom infrastructure holds resources that need cleanup on shutdown (database pools, file handles, network connections), implement the Closeable interface:

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

class PostgresBankAccountRepository
  implements BankAccountViewRepository, Closeable
{
  constructor(private readonly pool: Pool) {}

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

  // ... repository methods
}

When domain.shutdown() is called, the domain automatically discovers all infrastructure components that implement Closeable and calls close() on each one. No additional registration is needed — just implement the interface.

See Graceful Shutdown for the full shutdown lifecycle.

Next Steps

On this page