js

Build Production-Ready CQRS Event Sourcing Systems with TypeScript, NestJS, and EventStore

Master Event Sourcing with TypeScript, EventStore & NestJS. Build production-ready CQRS systems with versioning, snapshots & monitoring. Start coding!

Build Production-Ready CQRS Event Sourcing Systems with TypeScript, NestJS, and EventStore

I’ve spent years building systems that worked perfectly until they didn’t. When a critical bug emerged or a user asked “why did my balance change?”, we’d scramble through database logs, trying to piece together a story from fragmented state changes. This frustration led me down a different path—one where every change tells its own story. Today, I want to share how we can build systems that remember everything.

Think about your favorite book. You don’t just read the last page to understand the story. Why should our software only know its current state? Event sourcing flips this script. Instead of overwriting data, we store every change as an immutable fact.

Let’s create a simple story together. Imagine we’re building a payment system.

class PaymentInitiated {
  constructor(
    public paymentId: string,
    public amount: number,
    public currency: string,
    public timestamp: Date = new Date()
  ) {}
}

class PaymentCompleted {
  constructor(
    public paymentId: string,
    public transactionId: string,
    public timestamp: Date = new Date()
  ) {}
}

class PaymentFailed {
  constructor(
    public paymentId: string,
    public reason: string,
    public timestamp: Date = new Date()
  ) {}
}

See how each event tells a clear part of the story? But how do we actually use these events to build up our current application state?

We start with aggregates—the guardians of our business rules. An aggregate is like a character in our story that reacts to events.

class Payment {
  private status: 'initiated' | 'completed' | 'failed' = 'initiated';
  private events: any[] = [];
  
  constructor(private paymentId: string) {}
  
  static initiate(paymentId: string, amount: number, currency: string): Payment {
    const payment = new Payment(paymentId);
    payment.applyEvent(new PaymentInitiated(paymentId, amount, currency));
    return payment;
  }
  
  complete(transactionId: string): void {
    if (this.status === 'failed') {
      throw new Error('Cannot complete a failed payment');
    }
    this.applyEvent(new PaymentCompleted(this.paymentId, transactionId));
  }
  
  private applyEvent(event: any): void {
    this.events.push(event);
    // Update internal state based on event
    if (event instanceof PaymentInitiated) {
      this.status = 'initiated';
    } else if (event instanceof PaymentCompleted) {
      this.status = 'completed';
    }
  }
  
  getUncommittedEvents(): any[] {
    return [...this.events];
  }
}

But where do we store these events permanently? That’s where EventStoreDB shines. It’s built for exactly this purpose.

import { EventStoreDBClient } from '@eventstore/db-client';

const client = EventStoreDBClient.connectionString(
  'esdb://localhost:2113?tls=false'
);

async function saveEvents(streamName: string, events: any[]) {
  const serializedEvents = events.map(event => ({
    type: event.constructor.name,
    data: JSON.parse(JSON.stringify(event)),
    metadata: {
      timestamp: new Date().toISOString(),
      version: '1.0'
    }
  }));
  
  await client.appendToStream(streamName, serializedEvents);
}

Now, here’s a question that might surprise you: what happens when we need to change our event structure months from now? How do we handle events written with the old format?

We need versioning. Let me show you a practical approach.

class PaymentInitiatedV1 {
  constructor(
    public paymentId: string,
    public amount: number,
    public currency: string
  ) {}
}

class PaymentInitiatedV2 {
  constructor(
    public paymentId: string,
    public amountInCents: number,
    public currency: string,
    public customerId?: string
  ) {}
  
  static fromV1(event: PaymentInitiatedV1, customerId: string): PaymentInitiatedV2 {
    return new PaymentInitiatedV2(
      event.paymentId,
      event.amount * 100,
      event.currency,
      customerId
    );
  }
}

When we read events back, we transform older versions to the current format. This keeps our business logic clean while maintaining backward compatibility.

But what about performance? Replaying thousands of events for a single aggregate could be slow. That’s where snapshots help.

class PaymentSnapshot {
  constructor(
    public paymentId: string,
    public status: string,
    public version: number,
    public lastUpdated: Date
  ) {}
}

async function loadPayment(paymentId: string): Promise<Payment> {
  // Try to load from snapshot first
  const snapshot = await loadSnapshot(paymentId);
  const payment = new Payment(paymentId);
  
  if (snapshot) {
    payment.loadFromSnapshot(snapshot);
    // Only load events that occurred after the snapshot
    const events = await loadEventsAfterVersion(paymentId, snapshot.version);
    events.forEach(event => payment.applyEvent(event));
  } else {
    // Load all events
    const events = await loadAllEvents(paymentId);
    events.forEach(event => payment.applyEvent(event));
  }
  
  return payment;
}

Notice how we combine the snapshot with subsequent events? This gives us both performance and complete history.

Now, how do we actually use this in a NestJS application? Let’s look at a command handler.

@CommandHandler(CompletePaymentCommand)
export class CompletePaymentHandler implements ICommandHandler<CompletePaymentCommand> {
  constructor(
    private repository: PaymentRepository,
    private eventPublisher: EventPublisher
  ) {}
  
  async execute(command: CompletePaymentCommand): Promise<void> {
    const payment = await this.repository.findById(command.paymentId);
    
    if (!payment) {
      throw new Error('Payment not found');
    }
    
    payment.complete(command.transactionId);
    
    await this.repository.save(payment);
    
    // Publish events for other parts of the system
    payment.getUncommittedEvents().forEach(event => {
      this.eventPublisher.publish(event);
    });
  }
}

This handler loads our aggregate, applies the business logic, saves the new events, and publishes them. Other parts of our system can react to these events without being tightly coupled.

What about reading data? That’s where projections come in. They build specialized views from our events.

class PaymentProjection {
  private payments = new Map<string, any>();
  
  @EventHandler(PaymentInitiated)
  handlePaymentInitiated(event: PaymentInitiated): void {
    this.payments.set(event.paymentId, {
      paymentId: event.paymentId,
      amount: event.amount,
      currency: event.currency,
      status: 'initiated',
      createdAt: event.timestamp
    });
  }
  
  @EventHandler(PaymentCompleted)
  handlePaymentCompleted(event: PaymentCompleted): void {
    const payment = this.payments.get(event.paymentId);
    if (payment) {
      payment.status = 'completed';
      payment.completedAt = event.timestamp;
      payment.transactionId = event.transactionId;
    }
  }
  
  getPayment(paymentId: string): any {
    return this.payments.get(paymentId);
  }
  
  getCompletedPayments(): any[] {
    return Array.from(this.payments.values())
      .filter(p => p.status === 'completed');
  }
}

This projection gives us fast read access without querying the event stream directly. Different projections can serve different needs—one for customer dashboards, another for reporting, another for notifications.

Testing becomes more straightforward too. We can verify our system’s behavior by checking the events it produces.

describe('Payment', () => {
  it('should complete successfully', () => {
    const payment = Payment.initiate('pay-123', 100, 'USD');
    payment.complete('txn-456');
    
    const events = payment.getUncommittedEvents();
    expect(events).toHaveLength(2);
    expect(events[0]).toBeInstanceOf(PaymentInitiated);
    expect(events[1]).toBeInstanceOf(PaymentCompleted);
  });
  
  it('should reject completion of failed payments', () => {
    const payment = Payment.initiate('pay-123', 100, 'USD');
    payment.fail('insufficient_funds');
    
    expect(() => {
      payment.complete('txn-456');
    }).toThrow('Cannot complete a failed payment');
  });
});

We’re testing the behavior, not the implementation. The events tell us exactly what happened, making tests more meaningful and easier to debug.

In production, we need to think about monitoring. Since every state change is an event, we have perfect visibility.

class MonitoringDecorator {
  constructor(
    private handler: ICommandHandler<any>,
    private metrics: MetricsService
  ) {}
  
  async execute(command: any): Promise<void> {
    const startTime = Date.now();
    
    try {
      await this.handler.execute(command);
      this.metrics.recordSuccess(command.constructor.name, Date.now() - startTime);
    } catch (error) {
      this.metrics.recordFailure(command.constructor.name, error);
      throw error;
    }
  }
}

We can decorate our handlers to collect metrics, log events, or add tracing. The event-driven nature makes these cross-cutting concerns easier to implement.

Now, I want to pause and ask: can you see how this changes how we think about our systems? Instead of asking “what is the current state?” we can ask “what happened?” This shift in perspective gives us audit trails, temporal queries, and the ability to rebuild our system from scratch if needed.

But what about errors? In traditional systems, a failed transaction might leave inconsistent state. With event sourcing, we can use compensating events.

class CompensationService {
  async compensate(paymentId: string, reason: string): Promise<void> {
    const events = await loadEvents(paymentId);
    
    // Check if payment was completed
    const completedEvent = events.find(e => e instanceof PaymentCompleted);
    
    if (completedEvent) {
      // Create a compensating event
      await saveEvent(new PaymentRefunded(
        paymentId,
        completedEvent.transactionId,
        reason
      ));
      
      // Update projections
      await updateProjections();
    }
  }
}

We add new events to correct issues, rather than trying to fix corrupted state. The complete history remains intact and truthful.

Deployment strategies change too. Since projections are derived data, we can rebuild them from events. This means we can deploy new projection code, throw away the old projections, and rebuild from the event stream.

async function rebuildProjection(projection: Projection): Promise<void> {
  // Clear existing projection data
  await projection.clear();
  
  // Read all events from the beginning
  const events = await readAllEventsFromStart();
  
  // Replay all events
  for (const event of events) {
    await projection.handle(event);
  }
}

This gives us confidence in our deployments. If something goes wrong, we can always roll back and rebuild.

As we scale, we might distribute our events across different services. Event-driven architecture naturally supports this.

class EventDispatcher {
  async dispatch(event: any): Promise<void> {
    // Store in EventStoreDB
    await eventStore.append(event);
    
    // Publish to message broker for other services
    await messageBus.publish({
      type: event.constructor.name,
      data: event,
      metadata: {
        source: 'payments-service',
        timestamp: new Date().toISOString()
      }
    });
  }
}

Other services can subscribe to these events and maintain their own projections, creating a decoupled system that can evolve independently.

The journey from storing only current state to preserving complete history transforms how we build, debug, and understand our systems. It’s not just about technology—it’s about creating systems that tell their own stories, systems we can trust because we can see every step they’ve taken.

What parts of your current system would benefit from this complete history? How would debugging change if you could see every state change that led to an issue? I’d love to hear your thoughts and experiences. If this approach resonates with you, please share it with others who might be facing similar challenges. Your comments and questions help all of us learn and improve how we build software.

Keywords: Event Sourcing TypeScript, CQRS NestJS Implementation, EventStore Database Integration, TypeScript Domain Modeling, Advanced Event Handling Patterns, Event Versioning Schema Evolution, Production CQRS Systems, Event-Driven Architecture TypeScript, NestJS Event Sourcing Tutorial, EventStore CQRS Best Practices



Similar Posts
Blog Image
Complete Guide to Building Full-Stack Apps with Next.js and Prisma Integration in 2024

Learn how to integrate Next.js with Prisma for powerful full-stack development. Build type-safe applications with seamless database operations and API routes.

Blog Image
Build High-Performance GraphQL APIs: Complete NestJS, Prisma & Redis Caching Guide 2024

Build scalable GraphQL APIs with NestJS, Prisma, and Redis. Learn authentication, caching, DataLoader optimization, and production deployment strategies.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Full-Stack Development

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack web applications. Build database-driven apps with end-to-end TypeScript support.

Blog Image
Build High-Performance Event-Driven Microservices with Node.js, Fastify and Apache Kafka

Learn to build scalable event-driven microservices with Node.js, Fastify & Kafka. Master distributed transactions, error handling & monitoring. Complete guide with examples.

Blog Image
Complete Guide to Next.js Prisma ORM Integration: Build Type-Safe Full-Stack Apps in 2024

Learn how to integrate Next.js with Prisma ORM for type-safe database operations, seamless schema management, and optimized full-stack development workflows.

Blog Image
Build a Complete Rate-Limited API Gateway: Express, Redis, JWT Authentication Implementation Guide

Learn to build scalable rate-limited API gateways with Express, Redis & JWT. Master multiple rate limiting algorithms, distributed systems & production deployment.