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.
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 Architecture18 min read
Event-Driven Architecture in Enterprise Systems: Patterns and Trade-offs
A practitioner's guide to implementing event-driven architecture at scale. Covers message broker selection, event schema design, eventual consistency patterns, and lessons from production systems.
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.
Software Architecture19 min read
Building Resilient Distributed Systems: Patterns for Fault Tolerance
Build resilient distributed systems with circuit breakers, retries, and timeouts. Production patterns for handling failures, cascading errors, and maintaining availability.
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.