Six Months with Event Sourcing: A Production Retrospective
After managing a financial system handling over 50,000 transactions daily, I’ve learned that traditional CRUD (Create, Read, Update, Delete) often fails when accountability is non-negotiable. When a customer asks why their balance dropped by $50 overnight, a single database row showing the current state is useless. You need a timeline. You need a record of every intent and action that led to that result.
Mastering these patterns is the bridge between building basic apps and architecting systems that handle millions of dollars without breaking a sweat. NestJS makes this transition easier with its @nestjs/cqrs package. It provides a structured way to separate concerns. I’ve documented the specific patterns my team used to move past the theoretical examples and into a stable production environment.
The 5-Minute Setup
Setting up CQRS in NestJS is straightforward. You’ll need the core library first:
npm install @nestjs/cqrs
We’ll build a lean transaction system to demonstrate the flow. Start by registering the CqrsModule within your feature module to unlock the command and event buses.
// transactions.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { TransactionController } from './transaction.controller';
import { CreateTransactionHandler } from './commands/handlers/create-transaction.handler';
@Module({
imports: [CqrsModule],
controllers: [TransactionController],
providers: [CreateTransactionHandler],
})
export class TransactionsModule {}
Next, define a Command. This is a simple DTO that represents a user’s intent to change the system.
// create-transaction.command.ts
export class CreateTransactionCommand {
constructor(
public readonly accountId: string,
public readonly amount: number,
) {}
}
Finally, create the Handler. This is where your business logic lives. The handler captures the command and executes the operation.
// create-transaction.handler.ts
import { CommandHandler, ICommandHandler, EventPublisher } from '@nestjs/cqrs';
import { CreateTransactionCommand } from '../impl/create-transaction.command';
@CommandHandler(CreateTransactionCommand)
export class CreateTransactionHandler implements ICommandHandler<CreateTransactionCommand> {
constructor(private readonly publisher: EventPublisher) {}
async execute(command: CreateTransactionCommand) {
const { accountId, amount } = command;
// Logic: Validate account, check limits, etc.
return { success: true };
}
}
The Mechanics of State: Why Pair CQRS with Event Sourcing?
CQRS separates your write logic from your read logic. In our environment, this allowed us to optimize our SQL read replicas for complex reporting while keeping the write side lean and focused on data integrity. It’s about using the right tool for the job.
The Source of Truth: The Event Store
Traditional systems save the current balance. Event-sourced systems do the opposite: they save the history. You don’t store “Balance: $130.” Instead, you store Deposit($100), Withdraw($20), and Deposit($50). Your current balance is simply the sum of these events. This creates a native Audit Log. If you find a calculation bug, you can replay the entire history to see exactly when the state diverged.
The Data Journey
- Command: The user requests to “Transfer $500”.
- Handler: The system verifies the user has sufficient funds.
- Event: A
MoneyTransferredEventis written to the Event Store. - Projector: An async listener updates a read-only table so the UI can display the new balance instantly.
This decoupling allowed us to scale horizontally. We could change our UI’s data requirements without touching the core transaction logic.
Advanced Patterns: Sagas and Snapshots
Real-world workflows are rarely single-step. A bank transfer involves withdrawing from one account and depositing into another. If the second step fails, you need a way to roll back. This is where Sagas come in.
Coordinating Workflows with Sagas
A Saga is a long-running process that listens to events and triggers new commands. In NestJS, Sagas use RxJS observables to manage these complex streams.
@Injectable()
export class TransactionSagas {
@Saga()
transactionCreated = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(MoneyWithdrawnEvent),
map((event) => new DepositMoneyCommand(event.targetId, event.amount)),
);
}
}
Optimizing Performance with Snapshots
Replaying 10,000 events to calculate a single balance is a recipe for a timeout. We solved this by implementing Snapshots. Every 100 events, we save a “state checkpoint.” To calculate a balance now, we load the latest snapshot and only replay the few events that happened after it. This cut our aggregate load times from 2.5 seconds to under 40 milliseconds.
Lessons from a Live Environment
Building these systems taught me several lessons that documentation usually skips.
1. Events are Immutable
Never modify a persisted event. If you applied the wrong interest rate, don’t edit the database. Issue a Compensating Event to correct the balance instead. This keeps your audit trail honest and legally compliant.
2. Version Your Events Early
Business requirements change fast. Your TransactionCreatedEvent might need a taxId field by next month. Always include a version number in your event schema. Your code must handle v1 and v2 events simultaneously to avoid breaking production during updates.
3. Avoid Over-Engineering
CQRS adds boilerplate. If you’re building a simple blog or a CRUD-based internal tool, this is overkill. Use this pattern only for core domains where data history is a strict requirement. If a simple UPDATE statement works, use it. But if you need to prove how a value changed, NestJS CQRS is your best friend.
4. Use the Ecosystem
Don’t build an event store from scratch on your first try. The edge cases around concurrency and sequence IDs are brutal. Lean on proven libraries like nestjs-eventstore or dedicated solutions like EventStoreDB to handle the heavy lifting.
I no longer view a database as just a snapshot of the present. Instead, it’s a collection of stories about how we got here. In a world where data integrity is non-negotiable, that shift in perspective changes everything.

