I’ve been thinking about how modern applications handle complexity and scale. When every action matters and auditing is critical, traditional database approaches often fall short. That’s why I’m exploring event-driven architecture—a way to build systems that are not just reactive but also resilient, scalable, and auditable by design.
Have you ever wondered what it would be like to replay every change your system ever made? With event sourcing, you can do exactly that. Instead of storing just the current state, you capture every change as an immutable event. This gives you a complete history of your system’s behavior, making it possible to rebuild state at any point in time or debug issues with full context.
CQRS, or Command Query Responsibility Segregation, complements event sourcing by separating the write and read paths. This allows each side to be optimized independently—commands handle business logic and validation, while queries serve data in the most efficient way for consumption.
Let’s look at how to set up EventStoreDB, a purpose-built database for event sourcing:
docker run --name eventstore-node -d -p 2113:2113 -p 1113:1113 eventstore/eventstore:latest --insecure --run-projections=All
Once EventStore is running, you can start building your domain. Here’s how you might define a simple event in TypeScript:
class OrderCreated {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly amount: number
) {}
}
Events are the foundation. They represent facts—things that have happened in your system. But how do you turn these events into meaningful state? That’s where aggregates come in. An aggregate is a cluster of domain objects that can be treated as a single unit. It processes commands and produces events.
Here’s a simplified example of an Order aggregate:
class Order {
private status: string = 'pending';
create(orderId: string, customerId: string, amount: number) {
// Validate business rules
if (amount <= 0) throw new Error('Invalid amount');
// Apply the event
this.apply(new OrderCreated(orderId, customerId, amount));
}
private apply(event: OrderCreated) {
this.status = 'created';
// Update other state as needed
}
}
When you save these events to EventStore, they become the source of truth. But what about querying? That’s where projections come into play. Projections listen to events and update read models—optimized views of your data tailored for specific queries.
Imagine building a dashboard that shows order trends. Instead of querying a complex event stream every time, you could maintain a precomputed read model:
// Projection to update order summary
eventStore.subscribeToStream('orders', (event) => {
if (event.type === 'OrderCreated') {
readModel.updateOrderSummary(event.data.customerId, event.data.amount);
}
});
Handling errors and ensuring consistency are vital. Since events are immutable, you can’t change them—but you can emit new events to correct issues. This approach maintains a clear audit trail while allowing your system to evolve.
What happens when business requirements change and you need to modify your event structure? Versioning events carefully is key. You might add new fields while keeping backward compatibility, or write transformers to update old events to new formats during projection.
Testing event-sourced systems involves verifying that commands produce the correct events and that projections update read models accurately. Here’s a basic test example:
it('should create an order when valid', () => {
const order = new Order('order-123');
order.create('order-123', 'customer-456', 100);
const events = order.getUncommittedEvents();
expect(events[0]).toBeInstanceOf(OrderCreated);
});
Performance can be optimized by snapshotting—periodically saving the current state of an aggregate so you don’t have to replay all events from the beginning. EventStore supports this natively, making it efficient to load aggregates even with long histories.
Building with event sourcing and CQRS requires a shift in mindset. You’re designing for change, auditability, and scalability from the ground up. It might seem complex at first, but the benefits in traceability and flexibility are substantial.
Have you considered how event-driven architecture could simplify debugging in your current projects? Or how separating reads and writes might improve performance?
I hope this gives you a practical starting point for building with EventStore and Node.js. If you found this useful, feel free to share your thoughts in the comments or pass it along to others who might benefit. Let’s keep the conversation going.