I’ve spent countless hours building systems that handle complex state changes, and it wasn’t until I encountered event sourcing that everything clicked into place. The frustration of debugging production issues without proper audit trails led me down this path. Today, I want to share how you can implement this powerful pattern using EventStore and Node.js. Let’s build something robust together.
Event sourcing fundamentally changes how we think about data. Instead of storing only the current state, we persist every change as an immutable event. This approach gives us a complete history of what happened in our system. Imagine being able to replay events to reconstruct state at any point in time. How might this transform how you debug complex business processes?
Setting up our environment starts with EventStoreDB. I prefer using Docker for development consistency. Here’s a docker-compose.yml that gets you running quickly:
version: '3.8'
services:
eventstore:
image: eventstore/eventstore:21.10.0-buster-slim
environment:
EVENTSTORE_CLUSTER_SIZE: 1
EVENTSTORE_RUN_PROJECTIONS: All
EVENTSTORE_INSECURE: true
ports:
- "1113:1113"
- "2113:2113"
For our Node.js project, these dependencies form our foundation:
{
"dependencies": {
"@eventstore/db-client": "^5.0.0",
"uuid": "^9.0.0",
"zod": "^3.22.4"
}
}
Domain events are the heart of our system. They represent facts that have occurred in our business domain. Let me show you how I structure base events:
abstract class DomainEvent {
public readonly eventId: string;
public readonly timestamp: Date;
constructor(
public readonly aggregateId: string,
public readonly eventType: string
) {
this.eventId = uuidv4();
this.timestamp = new Date();
}
}
Now, consider an e-commerce system. What happens when a customer places an order? We might have events like OrderCreated or OrderConfirmed. Each event captures a specific business moment:
class OrderCreatedEvent extends DomainEvent {
constructor(
aggregateId: string,
public readonly customerId: string,
public readonly totalAmount: number
) {
super(aggregateId, 'OrderCreated');
}
}
Aggregates are crucial in event sourcing. They protect business invariants and handle commands by producing events. Here’s a simplified Order aggregate:
class Order {
private constructor(
public readonly id: string,
private status: string,
private version: number
) {}
static create(customerId: string, items: OrderItem[]) {
const orderId = uuidv4();
const event = new OrderCreatedEvent(orderId, customerId, calculateTotal(items));
return new Order(orderId, 'created', 0).applyEvent(event);
}
private applyEvent(event: DomainEvent) {
// Handle event application logic
this.version++;
return this;
}
}
Storing events requires a repository pattern. I’ve found this approach works well with EventStoreDB:
class EventStoreRepository {
async save(aggregateId: string, events: DomainEvent[], expectedVersion: number) {
const eventData = events.map(event => ({
type: event.eventType,
data: event.getData()
}));
await this.client.appendToStream(
aggregateId,
eventData,
{ expectedRevision: expectedVersion }
);
}
}
When we separate commands from queries, we enter CQRS territory. Projections build read models from our event stream. Have you considered how this separation could improve your application’s scalability?
class OrderProjection {
async handleOrderCreated(event: OrderCreatedEvent) {
await this.readModel.save({
id: event.aggregateId,
customerId: event.customerId,
status: 'created'
});
}
}
Event versioning is inevitable as systems evolve. I always include metadata that helps with schema migrations:
interface EventMetadata {
eventVersion: number;
timestamp: Date;
userId?: string;
}
Testing event-sourced systems requires a different mindset. I focus on testing the behavior rather than the state:
test('should create order when valid command', () => {
const command = new CreateOrder(customerId, items);
const events = handleCommand(command);
expect(events).toContainEqual(expect.any(OrderCreatedEvent));
});
Performance optimization often involves snapshotting. By periodically saving the current state, we avoid replaying all events every time:
class OrderSnapshot {
constructor(
public readonly orderId: string,
public readonly state: OrderState,
public readonly version: number
) {}
}
Common pitfalls? I’ve learned to avoid storing large payloads in events and to plan for event migration strategies early. Always consider how your events might need to change over time.
Event sourcing isn’t just about technology—it’s about building systems that truly reflect business reality. The audit capabilities alone have saved me weeks of investigation during critical incidents. What challenges could this approach solve in your current projects?
I hope this guide helps you start your event sourcing journey. If you found this valuable, please share it with others who might benefit. I’d love to hear about your experiences in the comments below—what patterns have worked well in your projects?