Best Practices

AI Rules for CQRS and Event Sourcing

AI uses the same model for reads and writes, losing audit history and scalability. Rules for command/query separation, event stores, read model projections, snapshotting, and when not to use CQRS.

8 min read·February 21, 2025

UPDATE overwrites previous state — what was the account balance on March 15th? Unknown. Only current state exists.

Command/query separation, append-only event store, read projections, snapshots, temporal queries

AI Uses One Model for Everything

AI generates applications with: a single database model for reads and writes (the same table schema serves the API list view and the complex write logic), no audit trail (UPDATE overwrites the previous state — what the value was before is lost forever), coupled read/write scaling (scaling reads requires scaling writes, and vice versa), query compromises (the schema is optimized for neither reads nor writes, mediocre at both), and no temporal queries (what was the account balance on March 15th? Unknown — only the current state exists).

CQRS and event sourcing solve this by: separating commands (writes) from queries (reads) into different models, storing every state change as an immutable event (append-only log — nothing is overwritten), projecting events into read-optimized views (materialized for fast queries), enabling temporal queries (replay events to any point in time), and scaling reads and writes independently. AI generates none of these — but importantly, not every application needs them.

These rules cover: command/query separation, event store design, read model projections, snapshotting for performance, temporal queries, and clear criteria for when CQRS is appropriate versus overkill.

Rule 1: Separate Command and Query Models

The rule: 'Commands (writes) and queries (reads) use different models optimized for their purpose. Command model: enforces business rules, validates invariants, produces events. Query model: denormalized, pre-computed, optimized for the specific read pattern. Example: the command model validates that an order total matches item prices. The query model stores a pre-computed order summary with customer name, item count, and formatted total — ready to display without joins.'

For the separation benefit: 'Read and write patterns are fundamentally different. Writes need: validation, business rules, transactional integrity, and normalization. Reads need: fast retrieval, denormalization (no joins), filtering, sorting, and pagination. A single model compromises both: normalized for writes (slow reads with joins) or denormalized for reads (complex writes with data duplication). CQRS: each model is optimized for its purpose.'

AI generates: const orders = await db.select().from(ordersTable).leftJoin(usersTable).leftJoin(itemsTable).where(...) — a complex join query that the write-optimized schema forces. With a read model: const orders = await db.select().from(orderSummaryView) — pre-joined, pre-computed, indexed for this exact query. The read model is a materialized view updated by events from the write side.

  • Command model: validates, enforces rules, produces events — normalized
  • Query model: denormalized, pre-computed, indexed for specific read patterns
  • No joins in read queries — the projection already contains all needed data
  • Scale independently: more read replicas without affecting write throughput
  • Different databases possible: Postgres for writes, Elasticsearch for full-text search reads

Rule 2: Append-Only Event Store

The rule: 'Store every state change as an immutable event in an append-only log. Never UPDATE or DELETE events. An order lifecycle: OrderCreated, ItemAdded, ItemRemoved, PaymentProcessed, OrderShipped. The current state is the result of replaying all events in order. The event store is: append-only (events are facts that happened — they cannot unhappen), immutable (no retroactive edits), and ordered (sequence number or timestamp per aggregate).'

For event structure: 'Each event: { aggregateId: "order-123", sequenceNumber: 4, type: "ItemAdded", data: { itemId: "prod-456", quantity: 2, price: 29.99 }, metadata: { userId: "user-789", timestamp: "2026-03-28T14:30:00Z", correlationId: "req-abc" } }. The aggregateId groups events for one entity. The sequenceNumber ensures ordering. The metadata provides audit context — who, when, and from which request.'

AI generates: UPDATE orders SET status = 'shipped' WHERE id = 123 — the previous status is gone. When was it paid? When was the last item added? Who changed the status? Unknown. Event sourcing: every state transition is recorded. The complete history is available. Replay to any point in time. Full audit trail by design, not as an afterthought.

💡 Full History by Design

UPDATE orders SET status = 'shipped' — the previous status is gone forever. Event sourcing: OrderCreated, PaymentProcessed, OrderShipped. Every transition recorded. Replay to any point in time. Full audit trail by design, not as an afterthought.

Rule 3: Read Model Projections

The rule: 'Build read models by projecting events into query-optimized views. A projection listens to events and updates a denormalized read table: on OrderCreated: INSERT INTO order_summaries (id, customer_name, status, total, item_count) VALUES (...). On ItemAdded: UPDATE order_summaries SET item_count = item_count + 1, total = total + item_price WHERE id = orderId. The read model is always derivable from events — if the projection has a bug, fix it and rebuild from the event store.'

For multiple projections: 'The same events can power multiple read models: (1) order_summaries for the dashboard (denormalized, fast), (2) order_search in Elasticsearch (full-text searchable), (3) order_analytics in a time-series database (aggregated metrics), (4) order_csv for export (flat file format). Each projection is optimized for its consumer. Adding a new projection requires zero changes to the write side — it replays existing events.'

AI generates: one database table serving: the API list endpoint, the admin search, the analytics dashboard, and the CSV export. Each use case needs different columns, indexes, and formats. The table is a compromise that serves none well. Projections: each consumer gets a perfectly shaped read model. The event store is the single source of truth; projections are disposable, rebuildable views.

⚠️ Projections Are Disposable

Read models are derived from events — if a projection has a bug, fix it and rebuild from the event store. The event store is the source of truth; projections are disposable, rebuildable views. Adding a new read model requires zero changes to the write side.

Rule 4: Snapshotting for Performance

The rule: 'For aggregates with many events (1000+), store periodic snapshots to avoid replaying the entire history on every load. Snapshot: { aggregateId: "order-123", version: 500, state: { /* current state at event 500 */ } }. To load: fetch the latest snapshot (version 500), then replay only events 501+ (instead of all 1000+). Snapshot every N events (100 or 500) or every N minutes. The snapshot is a cache — it can be deleted and rebuilt from events.'

For snapshot strategy: 'Snapshot frequency depends on: event volume (high-volume aggregates need more frequent snapshots), load time budget (how fast must the aggregate load?), and storage cost (each snapshot uses storage). A shopping cart with 5 events: no snapshot needed. A user account with 10,000 events over 3 years: snapshot every 100 events, load time drops from 2 seconds to 20ms.'

AI generates: replays all events from the beginning on every load. For a 3-year-old account with 10,000 events: 2-second load time on every request. With a snapshot at event 9,900: load the snapshot (1ms), replay 100 events (20ms), total 21ms. Same result, 100x faster. The snapshot is an optimization — the event store remains the source of truth.

  • Snapshot every N events (100-500) for high-volume aggregates
  • Load: fetch latest snapshot + replay events after snapshot version
  • Snapshots are caches — deletable and rebuildable from events
  • 5 events: no snapshot needed. 10,000 events: snapshot essential
  • Snapshot frequency = event volume x load time budget x storage cost

Rule 5: When CQRS Is Appropriate (and When It Is Not)

The rule: 'Use CQRS/event sourcing when: (1) audit trail is a business requirement (finance, healthcare, legal), (2) read and write patterns differ significantly (complex writes, simple reads or vice versa), (3) temporal queries are needed (what was the state at time T?), (4) multiple read representations are needed (dashboard, search, analytics, export), (5) the domain is complex with many business rules (DDD aggregate boundaries map to event-sourced aggregates).'

For when not to use: 'Do not use CQRS/event sourcing for: simple CRUD applications (a blog, a todo list — the overhead exceeds the benefit), applications with few business rules (just storing and retrieving data), small teams (the conceptual complexity requires team understanding), and prototypes (validate the idea first, add architecture later). A regular Postgres table with created_at and updated_at covers 80% of applications. CQRS is for the 20% where audit, temporal queries, and read/write asymmetry justify the complexity.'

AI defaults to either: (1) simple CRUD for everything (no audit, no temporal queries, no read optimization), or (2) event sourcing for a todo app (massive overkill). The right choice is context-dependent. Most applications: CRUD with Postgres. Financial systems, compliance-heavy applications, and complex domains: CQRS and event sourcing. Match the architecture to the problem, not to the trend.

ℹ️ 80% CRUD, 20% CQRS

A blog or todo app with event sourcing: massive overkill. A financial system with audit requirements: CQRS is essential. Most applications are well-served by CRUD with Postgres. Match the architecture to the problem complexity, not to conference talk trends.

Complete CQRS and Event Sourcing Rules Template

Consolidated rules for CQRS and event sourcing.

  • Separate command (write) and query (read) models — each optimized for its purpose
  • Append-only event store — immutable facts, never UPDATE or DELETE events
  • Read model projections: denormalized views built from events — rebuildable, disposable
  • Multiple projections from same events: dashboard, search, analytics, export
  • Snapshotting for high-volume aggregates — avoid replaying 10,000 events on every load
  • Use when: audit required, temporal queries needed, read/write patterns diverge significantly
  • Do not use for: simple CRUD, few business rules, small teams, prototypes
  • 80% of apps: CRUD with Postgres. 20%: CQRS/ES for compliance and complex domains