Back to Journal
System Design

CQRS & Event Sourcing Best Practices for Enterprise Teams

Battle-tested best practices for CQRS & Event Sourcing tailored to Enterprise teams, including anti-patterns to avoid and a ready-to-use checklist.

Muneer Puthiya Purayil 10 min read

Command Query Responsibility Segregation (CQRS) paired with Event Sourcing represents one of the most powerful architectural patterns available to enterprise engineering teams. When applied correctly, these patterns unlock audit trails, temporal queries, and independent scaling of read and write workloads. When applied incorrectly, they introduce accidental complexity that compounds over quarters. This article distills patterns that have survived production at enterprise scale — along with the anti-patterns that nearly sank projects.

Understanding the Enterprise Context

Enterprise teams operate under constraints that fundamentally shape how CQRS and Event Sourcing should be implemented. Compliance requirements demand immutable audit logs. Multiple teams need to consume the same domain events without coupling. Release cycles span weeks, not hours. These realities make some patterns essential and others dangerous.

The core principle: your event store is your source of truth. Every state change is captured as an immutable event. The write side (command) validates business rules and emits events. The read side (query) builds projections optimized for specific access patterns. This separation lets you scale reads and writes independently and evolve read models without touching domain logic.

Best Practices for Enterprise CQRS & Event Sourcing

1. Design Events as First-Class Contracts

Events are your system's API. Treat them with the same rigor you would treat a public REST endpoint.

typescript
1// Well-designed event with explicit versioning
2interface OrderPlacedV2 {
3 eventType: 'OrderPlaced';
4 eventVersion: 2;
5 aggregateId: string;
6 timestamp: string;
7 payload: {
8 orderId: string;
9 customerId: string;
10 lineItems: Array<{
11 productId: string;
12 quantity: number;
13 unitPrice: number;
14 currency: string;
15 }>;
16 totalAmount: number;
17 currency: string;
18 };
19 metadata: {
20 correlationId: string;
21 causationId: string;
22 userId: string;
23 tenantId: string;
24 };
25}
26 

Every event should carry correlationId and causationId for distributed tracing. In enterprise environments with dozens of services consuming events, this is the only way to trace a business operation end-to-end.

2. Implement Event Versioning from Day One

Enterprise systems run for years. Your event schemas will evolve. Without a versioning strategy from the start, you will face a painful migration.

typescript
1class EventUpcaster {
2 private upcasters: Map<string, (event: any) => any> = new Map();
3 
4 register(eventType: string, fromVersion: number, upcaster: (event: any) => any) {
5 this.upcasters.set(`${eventType}:v${fromVersion}`, upcaster);
6 }
7 
8 upcast(event: StoredEvent): DomainEvent {
9 let current = event;
10 while (current.eventVersion < this.getCurrentVersion(current.eventType)) {
11 const key = `${current.eventType}:v${current.eventVersion}`;
12 const upcaster = this.upcasters.get(key);
13 if (!upcaster) throw new Error(`Missing upcaster for ${key}`);
14 current = upcaster(current);
15 }
16 return current as DomainEvent;
17 }
18 
19 private getCurrentVersion(eventType: string): number {
20 // Registry of current versions per event type
21 const versions: Record<string, number> = {
22 'OrderPlaced': 2,
23 'OrderShipped': 3,
24 };
25 return versions[eventType] ?? 1;
26 }
27}
28 

Use upcasting — transforming old event versions to new ones on read — rather than migrating stored events. The event store remains immutable.

3. Keep Aggregates Small and Focused

Enterprise teams often create aggregates that mirror entire database tables from their legacy system. This leads to aggregates with hundreds of events, slow rehydration, and contention.

typescript
1// Anti-pattern: God aggregate
2class OrderAggregate {
3 // Handles ordering, inventory, payments, shipping, returns...
4 // Hundreds of event types, thousands of events per instance
5}
6 
7// Better: Focused aggregate per bounded context
8class Order {
9 // Only ordering concerns: place, modify, cancel
10 // Typically 5-15 events per instance
11}
12 
13class Fulfillment {
14 // Shipping concerns: assign warehouse, pack, ship, deliver
15}
16 
17class Payment {
18 // Payment concerns: authorize, capture, refund
19}
20 

Target aggregates with fewer than 50 events in their typical lifecycle. Use snapshots once aggregates regularly exceed 100 events, but treat that as a code smell first.

4. Build Projections as Independent Consumers

Each projection should be an autonomous consumer of the event stream. This means independent deployment, independent failure, and independent replay capability.

typescript
1class OrderDashboardProjection {
2 private checkpoint: number = 0;
3 
4 async processEvent(event: DomainEvent, position: number): Promise<void> {
5 switch (event.eventType) {
6 case 'OrderPlaced':
7 await this.db.query(
8 `INSERT INTO order_dashboard (order_id, customer_id, status, total, placed_at)
9 VALUES ($1, $2, 'placed', $3, $4)
10 ON CONFLICT (order_id) DO NOTHING`,
11 [event.payload.orderId, event.payload.customerId,
12 event.payload.totalAmount, event.timestamp]
13 );
14 break;
15 case 'OrderShipped':
16 await this.db.query(
17 `UPDATE order_dashboard SET status = 'shipped', shipped_at = $2
18 WHERE order_id = $1`,
19 [event.payload.orderId, event.timestamp]
20 );
21 break;
22 }
23 this.checkpoint = position;
24 }
25 
26 async rebuild(): Promise<void> {
27 await this.db.query('TRUNCATE order_dashboard');
28 this.checkpoint = 0;
29 // Replay all events from position 0
30 }
31}
32 

The ability to rebuild projections from scratch is one of Event Sourcing's killer features. Design every projection to support full replay. Store checkpoint positions to enable resumable catch-up.

5. Implement Idempotent Event Handlers

In distributed enterprise systems, events will be delivered more than once. Network partitions, consumer restarts, and rebalancing all cause duplicate delivery.

typescript
1class IdempotentEventHandler {
2 async handle(event: DomainEvent): Promise<void> {
3 const eventId = `${event.aggregateId}-${event.eventVersion}-${event.timestamp}`;
4
5 const processed = await this.db.query(
6 'SELECT 1 FROM processed_events WHERE event_id = $1',
7 [eventId]
8 );
9
10 if (processed.rowCount > 0) return;
11 
12 await this.db.transaction(async (tx) => {
13 await this.processEvent(event, tx);
14 await tx.query(
15 'INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())',
16 [eventId]
17 );
18 });
19 }
20}
21 

Use transactional outbox patterns when projections must be exactly-once. For most read model updates, idempotent handlers with at-least-once delivery provide sufficient guarantees.

6. Establish a Schema Registry

With multiple teams producing and consuming events, a schema registry prevents breaking changes from propagating silently.

typescript
1// Event schema validation at publish time
2class EventPublisher {
3 constructor(
4 private eventStore: EventStore,
5 private schemaRegistry: SchemaRegistry
6 ) {}
7 
8 async publish(event: DomainEvent): Promise<void> {
9 const schema = await this.schemaRegistry.getSchema(
10 event.eventType,
11 event.eventVersion
12 );
13
14 const validation = schema.validate(event.payload);
15 if (!validation.valid) {
16 throw new SchemaValidationError(
17 `Event ${event.eventType} v${event.eventVersion} failed validation`,
18 validation.errors
19 );
20 }
21 
22 await this.eventStore.append(event);
23 }
24}
25 

Integrate schema validation into your CI pipeline. Breaking changes to event schemas should fail the build, not production consumers.

7. Plan for Eventual Consistency in the UI

Enterprise stakeholders often expect immediate consistency. CQRS is inherently eventually consistent between the write and read sides.

typescript
1// Command handler returns a version token
2async function placeOrder(command: PlaceOrderCommand): Promise<{ version: number }> {
3 const events = order.place(command);
4 const version = await eventStore.append(events);
5 return { version };
6}
7 
8// Query side accepts version token for read-your-writes
9async function getOrder(orderId: string, afterVersion?: number): Promise<OrderView> {
10 if (afterVersion) {
11 await this.waitForProjection(orderId, afterVersion, { timeout: 5000 });
12 }
13 return this.orderReadModel.findById(orderId);
14}
15 

Implement read-your-writes consistency by passing version tokens from commands back to queries. This gives users immediate feedback without sacrificing the architectural benefits of CQRS.

Need a second opinion on your system design architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Anti-Patterns to Avoid

Event Sourcing Everything

Not every bounded context benefits from Event Sourcing. CRUD-heavy domains like user profile management or configuration settings are better served by traditional state-based persistence. Apply Event Sourcing where you need audit trails, temporal queries, or complex domain logic.

Coupling Projections to Aggregate Internals

Projections should only depend on published events, never on aggregate internal state. When a projection reaches into aggregate internals, you lose the ability to evolve the write and read sides independently.

Using the Event Store as a Message Bus

The event store is for persistence, not inter-service communication. Use a dedicated message broker (Kafka, RabbitMQ) for event distribution. The event store should be the authoritative record; the message broker handles delivery guarantees and fan-out.

Skipping Compensating Actions

In enterprise workflows, operations fail. Without compensating events (e.g., OrderCancelled to reverse OrderPlaced), you end up with manual database patches — the exact scenario Event Sourcing is supposed to prevent.

Enterprise Readiness Checklist

  • Event schemas versioned with upcasting strategy
  • Schema registry integrated into CI/CD pipeline
  • All event handlers are idempotent
  • Projections support full rebuild from event stream
  • Correlation and causation IDs on every event
  • Aggregate snapshot strategy defined for long-lived aggregates
  • Monitoring dashboards for projection lag
  • Dead letter queue for failed event processing
  • Retention policy defined for event store growth
  • Load testing completed for peak event throughput
  • Disaster recovery plan includes event store backup/restore
  • Cross-team event contracts documented and governed

Conclusion

CQRS and Event Sourcing deliver exceptional value in enterprise environments where audit requirements, complex domain logic, and independent team scaling converge. The patterns demand upfront investment in event versioning, schema governance, and projection infrastructure — but that investment pays compounding returns as the system grows.

The most successful enterprise implementations treat events as first-class contracts, keep aggregates focused, and accept eventual consistency as a feature rather than a limitation. Start with one bounded context where the pattern clearly fits, prove the operational model, then expand.

FAQ

Need expert help?

Building with system design?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026