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 Type | Parameter Position | Receives |
|---|---|---|
| Aggregate decide handlers | Third parameter | TInfrastructure |
| Projection event handlers | Second parameter | TInfrastructure |
| Query handlers | Second parameter | TInfrastructure |
| Standalone command handlers | Second parameter | TInfrastructure & CQRSInfrastructure |
| Saga handlers | Third parameter | TInfrastructure & 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 handlersEventBus-- Publishes events to projections, sagas, and other subscribersQueryBus-- 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:
infrastructure()is called first to create user-provided servicesbuses(userInfra)is called with the resolved user infrastructureaggregatesis resolved: global config applied to all aggregates, or per-aggregate configs resolved individuallyprojectionsview stores are resolved per-projectionsagas.persistence()is called if sagas are configuredidempotency(),unitOfWork(),outbox.store()are resolved if configured- The
Domaininstance 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
- Domain Configuration -- The full configuration structure
- Persistence -- Event-sourced vs. state-stored strategies
- Persistence Adapters -- Production persistence using Drizzle, Prisma, or TypeORM
- Testing -- Using in-memory implementations for testing