I’ve been reflecting on how we can build systems that not only handle complex business logic but also provide a complete history of every change. In my experience with modern applications, the need for auditability, scalability, and resilience has become paramount. That’s why I want to share my journey with event sourcing using Node.js, TypeScript, and EventStore. This approach has transformed how I think about data persistence and system design. Let’s explore how you can implement this powerful architecture in your projects.
Have you ever considered what it would mean to have a complete record of every state change in your application? Event sourcing answers this by storing all changes as immutable events rather than just the current state. This means you can reconstruct any past state by replaying events. For instance, in a banking system, instead of updating an account balance, you’d store events like “AccountOpened,” “MoneyDeposited,” or “MoneyWithdrawn.” This provides a natural audit trail and enables powerful features like time-travel debugging.
Why might this matter for your projects? Imagine debugging a production issue where you need to understand exactly what led to a specific state. With event sourcing, you can replay events up to that point. Or consider compliance requirements where every change must be logged. Event sourcing makes this inherent to your architecture rather than an afterthought.
Let’s start by setting up our environment. I prefer using Node.js with TypeScript for type safety and better developer experience. First, initialize a new project and install the necessary dependencies.
npm init -y
npm install express uuid @eventstore/db-client
npm install -D typescript @types/node @types/express
Here’s a basic TypeScript configuration to get started:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true
}
}
Now, let’s define what an event looks like in our system. Events are the heart of event sourcing—they represent facts that have occurred in the system.
interface BaseEvent {
eventId: string;
aggregateId: string;
eventType: string;
timestamp: Date;
}
class AccountOpenedEvent implements BaseEvent {
eventId: string;
timestamp: Date;
constructor(
public aggregateId: string,
public eventType: string,
public accountHolder: string,
public initialBalance: number
) {
this.eventId = require('uuid').v4();
this.timestamp = new Date();
}
}
But how do we manage the state that results from these events? This is where aggregates come in. An aggregate is a cluster of domain objects that can be treated as a single unit. For example, a BankAccount aggregate would handle all events related to an account.
class BankAccount {
private balance: number = 0;
private events: BaseEvent[] = [];
constructor(private id: string) {}
applyEvent(event: BaseEvent): void {
this.events.push(event);
if (event.eventType === 'MoneyDeposited') {
this.balance += (event as any).amount;
} else if (event.eventType === 'MoneyWithdrawn') {
this.balance -= (event as any).amount;
}
}
getCurrentState(): { balance: number } {
return { balance: this.balance };
}
}
What happens when you need to query this data efficiently? That’s where CQRS (Command Query Responsibility Segregation) comes in. Commands change state (e.g., deposit money), while queries read state. In event sourcing, reads are often handled by projections that build read-optimized views from events.
Connecting to EventStore is straightforward. EventStore is a database designed for event sourcing. Here’s how you might append an event:
import { EventStoreDBClient } from '@eventstore/db-client';
const client = EventStoreDBClient.connectionString('esdb://localhost:2113');
async function appendEvent(streamName: string, event: BaseEvent) {
await client.appendToStream(streamName, event);
}
When building projections, you create views that aggregate events into useful read models. For example, a projection might maintain a list of all accounts with their current balances.
class AccountBalanceProjection {
private balances: Map<string, number> = new Map();
processEvent(event: BaseEvent): void {
if (event.eventType === 'MoneyDeposited') {
const current = this.balances.get(event.aggregateId) || 0;
this.balances.set(event.aggregateId, current + (event as any).amount);
}
}
getBalance(accountId: string): number {
return this.balances.get(accountId) || 0;
}
}
Handling errors and ensuring consistency is crucial. In event sourcing, commands should be idempotent where possible, meaning processing the same command multiple times doesn’t change the outcome. This helps with retries and failure recovery.
Testing event-sourced systems involves verifying that events are correctly applied and that the state is reconstructed properly. I often write tests that replay events and check the resulting state.
describe('BankAccount', () => {
it('should correctly apply deposit events', () => {
const account = new BankAccount('acc-123');
const event = new AccountOpenedEvent('acc-123', 'AccountOpened', 'John Doe', 100);
account.applyEvent(event);
expect(account.getCurrentState().balance).toBe(100);
});
});
As your system evolves, you might need to handle event versioning. If the structure of an event changes, you can version events and write upgraders to transform old events to new formats.
One common challenge is performance when replaying many events. Snapshots can help by storing the state at certain points, so you don’t have to replay all events from the beginning.
Throughout my projects, I’ve found that event sourcing encourages a domain-driven design approach. It forces you to think carefully about what events are meaningful in your business domain.
What if you need to handle high-throughput scenarios? Event sourcing scales well because appending events is fast, and read models can be built asynchronously.
I encourage you to start small—perhaps with a bounded context where auditability is critical. Experiment with storing events and rebuilding state. The insights you gain from seeing every change laid out chronologically can be transformative.
I’d love to hear how you implement event sourcing in your own work. If this guide sparked ideas or questions, please share your thoughts in the comments below. Don’t forget to like and share this with others who might benefit from it!