Software Architecture

CQRS and Event Sourcing: When and Why to Use Them

Complete CQRS and Event Sourcing implementation guide with TypeScript and Node.js. Covers event stores, projections, snapshots, and when these patterns are worth the complexity.

Khalid Aboubakr
20 min read
CqrsEvent SourcingDddArchitecture PatternsEventual ConsistencyEvent Store

Introduction

CQRS (Command Query Responsibility Segregation) and Event Sourcing are powerful patterns that can solve specific problems elegantly. They can also add tremendous complexity when applied inappropriately.

After implementing these patterns in healthcare audit systems, financial transaction platforms, and government compliance systems, I've developed a clear understanding of when they shine and when they're overkill.

Understanding CQRS

CQRS separates read and write operations into different models. At its simplest:

// Traditional approach: same model for reads and writes class OrderService { async createOrder(data: CreateOrderDto): Promise<Order> { const order = new Order(data); await this.orderRepository.save(order); return order; } async getOrder(id: string): Promise<Order> { return this.orderRepository.findById(id); } async getOrdersWithDetails(userId: string): Promise<OrderWithDetails[]> { // Awkward: need to join multiple tables, transform data const orders = await this.orderRepository.findByUserId(userId); return Promise.all(orders.map(async (order) => { const customer = await this.customerRepository.findById(order.customerId); const items = await this.orderItemRepository.findByOrderId(order.id); const products = await Promise.all( items.map(item => this.productRepository.findById(item.productId)) ); return this.assembleOrderWithDetails(order, customer, items, products); })); } }
// CQRS approach: separate models optimized for their purpose // Write side: enforces business rules class OrderCommandHandler { async handle(command: CreateOrderCommand): Promise<void> { const order = Order.create(command); await this.orderRepository.save(order); await this.eventPublisher.publish(order.pullEvents()); } } // Read side: optimized for queries class OrderQueryHandler { async getOrdersWithDetails(userId: string): Promise<OrderDetailsView[]> { // Pre-computed, denormalized view optimized for this query return this.orderDetailsViewRepository.findByUserId(userId); } } // Projection updates the read model when events occur class OrderDetailsProjection { @Subscribe('OrderCreated') async onOrderCreated(event: OrderCreatedEvent): Promise<void> { await this.viewRepository.insert({ orderId: event.orderId, customerName: event.customerName, // Denormalized totalAmount: event.totalAmount, itemCount: event.items.length, createdAt: event.timestamp }); } }

Understanding Event Sourcing

Event Sourcing stores state as a sequence of events rather than current state:

// Traditional: store current state // orders table: { id, status, total_amount, customer_id, updated_at } // Event Sourcing: store events // order_events table: // { stream_id, version, event_type, event_data, timestamp } // // OrderCreated: { orderId: "123", customerId: "456", items: [...] } // ItemAdded: { orderId: "123", productId: "789", quantity: 2 } // PaymentReceived: { orderId: "123", amount: 99.99, method: "card" } // OrderShipped: { orderId: "123", trackingNumber: "TRACK123" } class Order extends AggregateRoot { private id: OrderId; private status: OrderStatus; private items: OrderItem[] = []; private payments: Payment[] = []; // Rebuild state from events static fromEvents(events: DomainEvent[]): Order { const order = new Order(); events.forEach(event => order.apply(event)); return order; } private apply(event: DomainEvent): void { switch (event.type) { case 'OrderCreated': this.id = new OrderId(event.data.orderId); this.status = OrderStatus.PENDING; break; case 'ItemAdded': this.items.push(new OrderItem(event.data)); break; case 'PaymentReceived': this.payments.push(new Payment(event.data)); if (this.isFullyPaid()) { this.status = OrderStatus.PAID; } break; case 'OrderShipped': this.status = OrderStatus.SHIPPED; break; } } // Commands produce events addItem(productId: ProductId, quantity: number): void { if (this.status !== OrderStatus.PENDING) { throw new CannotModifyOrderError(this.status); } this.raiseEvent(new ItemAddedEvent(this.id, productId, quantity)); } }

When to Use CQRS

Scenario 1: Read/Write Asymmetry

When reads vastly outnumber writes and have different performance characteristics:

// E-commerce product catalog // Writes: ~100/hour (admin updates) // Reads: ~100,000/hour (customer browsing) // Write model: normalized, consistent class ProductWriteModel { id: string; name: string; description: string; basePrice: Money; categoryId: string; inventoryCount: number; } // Read model: denormalized, fast class ProductReadModel { id: string; name: string; description: string; displayPrice: string; // Pre-formatted categoryName: string; // Denormalized from category categoryPath: string; // "Electronics > Computers > Laptops" isInStock: boolean; // Computed from inventory rating: number; // Aggregated from reviews reviewCount: number; // Pre-computed imageUrls: string[]; // Flattened from media table }

Scenario 2: Complex Querying Requirements

When the domain model doesn't naturally support required queries:

// Dashboard showing order analytics // Trying to do this with the write model is painful // Read model optimized for analytics class OrderAnalyticsView { // Time-series data for charts dailyOrderCounts: Map<string, number>; dailyRevenue: Map<string, Money>; // Aggregations averageOrderValue: Money; topSellingProducts: ProductSalesSummary[]; customerSegmentBreakdown: SegmentBreakdown[]; // Pre-computed for instant response revenueGrowthPercentage: number; orderCountTrend: 'increasing' | 'decreasing' | 'stable'; }

When to Use Event Sourcing

Scenario 1: Audit Requirements

When you must explain exactly what happened and when:

// Healthcare system: complete audit trail required by regulation class PatientMedicationHistory { getFullHistory(patientId: PatientId): MedicationEvent[] { return this.eventStore.getEvents(patientId, 'medication'); // Returns: // [ // { type: 'MedicationPrescribed', drug: 'Amoxicillin', dosage: '500mg', prescribedBy: 'Dr. Smith', at: '2024-01-15T09:00:00Z' }, // { type: 'MedicationDispensed', drug: 'Amoxicillin', pharmacy: 'CVS #123', at: '2024-01-15T14:30:00Z' }, // { type: 'MedicationDoseAdministered', drug: 'Amoxicillin', administeredBy: 'Nurse Johnson', at: '2024-01-15T18:00:00Z' }, // { type: 'AdverseReactionReported', drug: 'Amoxicillin', reaction: 'Mild rash', reportedBy: 'Patient', at: '2024-01-16T08:00:00Z' }, // { type: 'MedicationDiscontinued', drug: 'Amoxicillin', reason: 'Adverse reaction', discontinuedBy: 'Dr. Smith', at: '2024-01-16T10:00:00Z' } // ] } }

Scenario 2: Temporal Queries

When you need to answer "what was the state at time X?":

class AccountBalanceService { // What was the balance on December 31st for tax reporting? getBalanceAtTime(accountId: AccountId, asOf: Date): Money { const events = this.eventStore.getEventsUntil(accountId, asOf); return events.reduce((balance, event) => { switch (event.type) { case 'Deposited': return balance.add(event.amount); case 'Withdrawn': return balance.subtract(event.amount); default: return balance; } }, Money.zero()); } // Reconstruct complete transaction history getStatementForPeriod(accountId: AccountId, from: Date, to: Date): Statement { const events = this.eventStore.getEventsBetween(accountId, from, to); return new Statement(events); } }

Scenario 3: Complex Business Logic with Replays

When you need to fix bugs by replaying events with corrected logic:

// Bug discovered: loyalty points weren't calculated correctly for 3 months // With event sourcing, we can fix it: class LoyaltyPointsProjection { // Version 1 (buggy): didn't account for promotions // Version 2 (fixed): correctly applies promotional multipliers async rebuild(): Promise<void> { // Clear current read model await this.pointsRepository.truncate(); // Replay all events with fixed logic const allEvents = await this.eventStore.getAllEvents('purchase'); for (const event of allEvents) { await this.applyEvent(event); // Uses corrected calculation } } }

When NOT to Use These Patterns

Simple CRUD Applications

// Blog posts, user profiles, simple inventory management // CRUD is perfectly fine! class BlogPostService { async create(data: CreatePostDto): Promise<Post> { const post = new Post(data); return this.repository.save(post); } async update(id: string, data: UpdatePostDto): Promise<Post> { const post = await this.repository.findById(id); post.update(data); return this.repository.save(post); } }

Small Teams Without Event Sourcing Experience

The learning curve is steep. If your team hasn't used these patterns before, start with simpler approaches.

When Strong Consistency is Required

Event sourcing and CQRS introduce eventual consistency. If your domain requires immediate consistency across all views, think carefully.

Implementation Considerations

Event Store Selection

// Option 1: Purpose-built (EventStoreDB) const eventStore = new EventStoreDBClient({ endpoint: 'localhost:2113' }); // Option 2: Build on existing database class PostgresEventStore implements EventStore { async append(streamId: string, events: DomainEvent[]): Promise<void> { await this.db.transaction(async (tx) => { const currentVersion = await tx.query( 'SELECT MAX(version) FROM events WHERE stream_id = $1', [streamId] ); for (const event of events) { await tx.query( `INSERT INTO events (stream_id, version, event_type, event_data, timestamp) VALUES ($1, $2, $3, $4, $5)`, [streamId, currentVersion + 1, event.type, JSON.stringify(event.data), event.timestamp] ); } }); } }

Snapshotting for Performance

class OrderRepository { async getById(orderId: OrderId): Promise<Order> { // Check for recent snapshot const snapshot = await this.snapshotStore.getLatest(orderId); // Load events since snapshot (or all events if no snapshot) const events = snapshot ? await this.eventStore.getEventsSince(orderId, snapshot.version) : await this.eventStore.getAllEvents(orderId); // Rebuild from snapshot + events const order = snapshot ? Order.fromSnapshot(snapshot.state) : new Order(); events.forEach(event => order.apply(event)); // Create new snapshot if too many events since last one if (events.length > 100) { await this.snapshotStore.save(orderId, order.toSnapshot(), order.version); } return order; } }

Conclusion

CQRS and Event Sourcing are powerful tools that solve real problems:

  • CQRS: When read and write patterns differ significantly
  • Event Sourcing: When audit trails, temporal queries, or replay capabilities are required

But they come with costs:

  • Increased complexity
  • Eventual consistency challenges
  • Steeper learning curve
  • More infrastructure to manage

Start simple. Add these patterns only when you have clear requirements that justify their complexity. A well-designed traditional architecture often serves better than a poorly implemented CQRS/ES system.

Related Articles

Software Architecture22 min read

Domain-Driven Design: Bounded Contexts in Practice

Learn how to implement bounded contexts in Domain-Driven Design. Practical guide covering context mapping, aggregates, domain events, and real examples from enterprise projects.

Backend Design20 min read

Database Design Patterns for Scale

Scale databases with sharding, replication, and partitioning. Covers PostgreSQL, MySQL, and MongoDB scaling patterns with real performance numbers from production systems.

Backend Design17 min read

Queue-Based Architecture for Reliable Processing

Build reliable message queue systems with Redis, RabbitMQ, and AWS SQS. Covers dead letter queues, idempotency, and real-world processing patterns.