Skip to content

Module configuration

Everything in this library is wired together by the EventSourcingModule. It exposes three static methods:

Method Where to import it What it’s for
forRoot(options) Your root module, once Configure the module with static options.
forRootAsync(options) Your root module, once Configure the module when the options depend on other providers, e.g. a config service.
forFeature({ events, serializers, imports }) Any feature module Register the events that belong to that feature module, and provide their serializers.

EventSourcingModule.forRoot() accepts the following options. All of them are optional:

Option Description
events The event classes to register globally. Optional, because feature modules can register their own events with forFeature.
eventStore The configuration of the event store. Needs a driver (the store class), can contain useDefaultPool and any option that the driver needs to connect. The module passes the driver every option except driver and useDefaultPool. Defaults to the in-memory event store.
snapshotStore The configuration of the snapshot store, which has the same shape as eventStore. Defaults to the in-memory snapshot store.
publishing { publisherTimeout, shutdownTimeout }: how long, in milliseconds, one call of an event publisher may take (30 000 by default), and how long the application’s shutdown waits for the running publishers and subscribers (10 000 by default). 0 disables a timeout. See event pub/sub.
defaultEventSerializer The serializer of every event without an @EventSerializer() of its own. Defaults to JsonEventSerializer; use ClassTransformerEventSerializer from @ocoda/event-sourcing/class-transformer for events with class-transformer decorators. See event serialization.

The in-memory stores are handy for testing and prototyping, but they lose everything when the process stops. When NODE_ENV is production and eventStore or snapshotStore is left out, the module logs a warning. Each integration library exports its own drivers and configuration interfaces, see installation.

import { Module } from '@nestjs/common';
import { EventSourcingModule } from '@ocoda/event-sourcing';
import {
PostgresEventStore,
PostgresSnapshotStore,
type PostgresEventStoreConfig,
type PostgresSnapshotStoreConfig,
} from '@ocoda/event-sourcing-postgres';
@Module({
imports: [
EventSourcingModule.forRoot<PostgresEventStoreConfig, PostgresSnapshotStoreConfig>({
eventStore: {
driver: PostgresEventStore,
connectionString: process.env.DATABASE_URL,
},
snapshotStore: {
driver: PostgresSnapshotStore,
connectionString: process.env.DATABASE_URL,
},
}),
],
})
export class AppModule {}

When you don’t want to list any events in the root module, you can leave out events completely and register them in your feature modules. For example, with the default in-memory stores that’s just:

import { Module } from '@nestjs/common';
import { EventSourcingModule } from '@ocoda/event-sourcing';
@Module({
imports: [EventSourcingModule.forRoot({})],
})
export class AppModule {}

Asynchronous configuration with forRootAsync

Section titled “Asynchronous configuration with forRootAsync”

Use EventSourcingModule.forRootAsync() when the options depend on something that isn’t available yet when the module is declared, such as environment configuration or a secrets provider. The options are resolved before the stores are created, and can be provided in three ways.

A factory function that returns the options (or a promise of them). Add the modules that provide its dependencies to imports and list those dependencies in inject, in the order of the factory’s parameters.

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventSourcingModule } from '@ocoda/event-sourcing';
import {
PostgresEventStore,
PostgresSnapshotStore,
type PostgresEventStoreConfig,
type PostgresSnapshotStoreConfig,
} from '@ocoda/event-sourcing-postgres';
@Module({
imports: [
EventSourcingModule.forRootAsync<PostgresEventStoreConfig, PostgresSnapshotStoreConfig>({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
eventStore: {
driver: PostgresEventStore,
connectionString: config.getOrThrow<string>('DATABASE_URL'),
},
snapshotStore: {
driver: PostgresSnapshotStore,
connectionString: config.getOrThrow<string>('DATABASE_URL'),
},
}),
}),
],
})
export class AppModule {}

forRootAsync also accepts the options object directly with useValue, which comes down to calling forRoot. It is deprecated in 4.0 and removed in 5.0: use forRoot(options) instead. Without useFactory, useClass or useExisting, forRootAsync throws an EventSourcingConfigurationException.

Registering events per feature module with forFeature

Section titled “Registering events per feature module with forFeature”

Every event that ends up in the event store has to be registered, otherwise the library can’t serialize or deserialize it and an UnregisteredEventException is thrown. Instead of collecting all events of your application in the root module, each feature module can register its own events. This keeps a bounded context self-contained: the module that owns the events, aggregates and handlers is also the one that registers them.

forFeature() takes three options, all of them optional:

Option Description
events The event classes of the feature module.
serializers The event serializers of those events. The feature module provides them, so you don’t list them in providers as well.
imports The modules that provide what the serializers inject.

In the example below an application has two feature modules, each registering its own events:

accounts/accounts.module.ts
import { Module } from '@nestjs/common';
import { EventSourcingModule } from '@ocoda/event-sourcing';
@Module({
imports: [
EventSourcingModule.forFeature({
events: [AccountOpenedEvent, AccountCreditedEvent, AccountDebitedEvent],
}),
],
providers: [AccountRepository, AccountSnapshotRepository, OpenAccountCommandHandler, CreditAccountCommandHandler],
})
export class AccountsModule {}
transfers/transfers.module.ts
import { Module } from '@nestjs/common';
import { EventSourcingModule } from '@ocoda/event-sourcing';
@Module({
imports: [
EventSourcingModule.forFeature({
events: [TransferRequestedEvent, TransferCompletedEvent],
}),
],
providers: [TransferRepository, RequestTransferCommandHandler, TransferCompletedEventSubscriber],
})
export class TransfersModule {}

The root module configures the stores once and imports the feature modules. It doesn’t need to know about their events:

app.module.ts
import { Module } from '@nestjs/common';
import { EventSourcingModule } from '@ocoda/event-sourcing';
import { PostgresEventStore, PostgresSnapshotStore } from '@ocoda/event-sourcing-postgres';
@Module({
imports: [
EventSourcingModule.forRoot({
eventStore: { driver: PostgresEventStore, connectionString: process.env.DATABASE_URL },
snapshotStore: { driver: PostgresSnapshotStore, connectionString: process.env.DATABASE_URL },
}),
AccountsModule,
TransfersModule,
],
})
export class AppModule {}

A serializer that needs other providers gets them from the feature’s imports:

accounts/accounts.module.ts
import { Module } from '@nestjs/common';
import { EventSourcingModule } from '@ocoda/event-sourcing';
@Module({
imports: [
EventSourcingModule.forFeature({
events: [AccountOpenedEvent],
serializers: [AccountOpenedEventSerializer],
imports: [CryptoModule], // provides what AccountOpenedEventSerializer injects
}),
],
})
export class AccountsModule {}

A few things to keep in mind:

  • forFeature exports nothing. The stores and buses come from the root module, which is global, see below.
  • The events belong to the application that imports the feature module. Each forFeature call is a module of its own, which the root module finds through Nest’s discovery when the application starts. Several Nest applications in one process, for example in tests, each register only the events of the modules they import. (3.x collected them in a registry shared by the whole process, when the file of the module was loaded.)
  • Registering the same event class in several places, in forRoot and in a forFeature for instance, is harmless: it is registered once. Two different classes with the same event name fail the bootstrap.

forRoot and forRootAsync register one global module. It provides and exports the EventStore, the SnapshotStore, the EventBus, the CommandBus, the QueryBus, the EventMap and the options (@InjectEventSourcingOptions()). That means you can inject them in any provider of your application without importing EventSourcingModule again.

import { Injectable } from '@nestjs/common';
import { CommandBus, EventStore } from '@ocoda/event-sourcing';
@Injectable()
export class TransferService {
constructor(
private readonly commandBus: CommandBus,
private readonly eventStore: EventStore,
) {}
}

Command handlers, query handlers, event subscribers, event publishers and event serializers don’t have to be registered anywhere. When the application starts, the library scans the providers of all modules of the application, regardless of where forRoot or forFeature were imported, and registers every provider that is decorated with one of these decorators:

Decorator Registered in
@CommandHandler() the CommandBus
@QueryHandler() the QueryBus
@EventSubscriber() the EventBus, see event pub/sub
@EventPublisher() the EventBus, see event pub/sub
@EventSerializer() the EventMap, see event serialization

Adding them to the providers of any module is enough, whether as a class, with useFactory or with useValue: the decorator is read from the class of the instance. Other classes, such as aggregate repositories and snapshot repositories (@Snapshot()), aren’t discovered: they are ordinary providers that you inject where you need them.

The module goes through these steps:

  1. Instantiating the providers: the event store and the snapshot store are created, connect, and create their default pools (unless useDefaultPool is false). A failure fails the bootstrap.
  2. Module initialization (onModuleInit): the providers of all modules are scanned, the whole configuration is checked, and the events, serializers, handlers, subscribers and publishers are registered. If a provider of another module uses the event store or a bus in its own onModuleInit before that, which can happen with a @Global() module of yours, the registration runs right then.
  3. Shutdown (app.close()): the EventBus waits for the running publishers and subscribers (at most publishing.shutdownTimeout), then the stores disconnect.

So from onModuleInit on, you can execute commands and queries and append or read events, and the publishers and subscribers receive every event appended from then on. That can be before the onModuleInit of your publisher has run: Nest initializes the deepest modules first, so a publisher that connects to its broker in its own onModuleInit may get events appended from an earlier hook before it is connected. Connect it in an async provider factory instead, or buffer until it is connected.

Command and query handlers may be request-scoped, transient, or depend on a request-scoped provider. The bus then resolves the handler for every call. Pass the request to execute to resolve the handler in the DI context of that request, where it can inject it with @Inject(REQUEST):

import { Controller, Inject, Injectable, Post, Req, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { CommandBus, CommandHandler, type ICommandHandler } from '@ocoda/event-sourcing';
import type { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
@CommandHandler(OpenAccountCommand)
export class OpenAccountCommandHandler implements ICommandHandler<OpenAccountCommand> {
constructor(
private readonly accountRepository: AccountRepository,
@Inject(REQUEST) private readonly request: Request,
) {}
async execute(command: OpenAccountCommand) {
// this.request is the request that the command was executed for
}
}
@Controller('accounts')
export class AccountController {
constructor(private readonly commandBus: CommandBus) {}
@Post()
open(@Req() request: Request) {
return this.commandBus.execute(new OpenAccountCommand(), { request });
}
}

The commands and queries executed for one request share its request-scoped instances, and those of a request-scoped controller that handles the request. Without a request, every call gets new instances and REQUEST is undefined. A singleton handler is used as it is, and ignores the request.

A request-scoped handler provided with useFactory has no instance to read its decorator from until it is resolved, so use the handler class as its token: { provide: OpenAccountCommandHandler, useFactory, inject: [REQUEST], scope: Scope.REQUEST }.

Subscribers, publishers and serializers are called outside of any request, for as long as the application runs, so they must be singletons (the default scope). A request-scoped or transient one fails the bootstrap; resolve request-scoped dependencies per call with ModuleRef.resolve() instead.

Before the module registers anything, it checks the whole configuration. When something is wrong, the bootstrap fails with one EventSourcingConfigurationException, whose issues list every problem that was found, each with a kind and a message that names the classes involved:

kind What is wrong
invalid-options Options that can’t work: forRootAsync() without useFactory, useClass or useExisting, a store without a driver class, an entry of events that isn’t a class, or a defaultEventSerializer without a for() method.
missing-metadata A registered event without @Event(), a serializer in forFeature({ serializers }) without @EventSerializer(), a handler whose decorator names no command or query class, or a subscriber that names no events.
duplicate-event-name Two event classes with the same event name.
duplicate-command-handler, duplicate-query-handler Two classes that handle the same command or query.
duplicate-event-serializer Two classes that serialize the same event.
unregistered-event A serializer or subscriber for an event that isn’t registered in forRoot or a forFeature.
non-static-provider A request-scoped or transient subscriber, publisher or serializer, see above.
class-transformer-decorators An event class, or a parent class, with class-transformer decorators (@Type, @Transform, @Expose or @Exclude) that would get the default JsonEventSerializer, which ignores them. Checked only when class-transformer is installed; see event serialization.

Providing the same class in several modules, or listing the same event more than once, is not a duplicate: it is registered once.

import { EventSourcingErrorCode, isEventSourcingError } from '@ocoda/event-sourcing';
try {
await app.init();
} catch (error) {
if (isEventSourcingError(error, EventSourcingErrorCode.EventSourcingConfiguration)) {
for (const { kind, message } of error.issues) console.error(kind, message);
}
throw error;
}