iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

CQRS

CQRS in practice: when it earns its keep, when it does not, and the minimum scaffolding to do it without ceremony.

CQRS — practical pattern

EXAMPLE
// ===== The idea =====
// Split the model into two:
//   - Command side: handles writes; owns invariants and validation
//   - Query side:   handles reads; optimised for the question being asked
// Communicate via events or a shared store. They do NOT need to match.

// ===== When CQRS earns its keep =====
// - Read shape != write shape (e.g. dashboard joins 6 tables; writes hit one)
// - Read traffic dwarfs write traffic; you want to scale them independently
// - You need projections in multiple shapes (search index, denorm view, audit)
// - The write model has rich invariants (DDD aggregates)

// ===== When CQRS is overkill =====
// - CRUD apps with one read shape per write shape
// - Small teams; the ceremony is not worth the wins
// - You only need it 'to be event-sourced one day' -> defer until needed

// ===== Minimum scaffolding (TypeScript-ish) =====
// commands
type Command =
  | { kind: 'CreateOrder'; id: string; customer: string; lines: Line[] }
  | { kind: 'CancelOrder'; id: string; reason: string };

interface CommandHandler<C extends Command> {
  handle(c: C): Promise<Event[]>;
}

// events
type Event =
  | { kind: 'OrderCreated'; id: string; customer: string; total: number; at: string }
  | { kind: 'OrderCancelled'; id: string; reason: string; at: string };

// command side: aggregate with invariants
class OrderAggregate {
  private state: 'new' | 'cancelled' | undefined;
  constructor(private id: string) {}
  create(customer: string, lines: Line[]): Event[] {
    if (this.state) throw new Error('already exists');
    if (!lines.length) throw new Error('no lines');
    const total = lines.reduce((s, l) => s + l.price * l.qty, 0);
    return [{ kind: 'OrderCreated', id: this.id, customer, total, at: new Date().toISOString() }];
  }
  cancel(reason: string): Event[] {
    if (this.state === 'cancelled') return [];        // idempotent
    if (this.state !== 'new') throw new Error('cannot cancel');
    return [{ kind: 'OrderCancelled', id: this.id, reason, at: new Date().toISOString() }];
  }
  apply(e: Event) {
    if (e.kind === 'OrderCreated') this.state = 'new';
    if (e.kind === 'OrderCancelled') this.state = 'cancelled';
  }
}

// query side: projection optimised for the dashboard
// table:  order_view (id PK, customer, total_cents, status, last_event_at)
class OrderProjection {
  constructor(private db: DB) {}
  async on(e: Event) {
    if (e.kind === 'OrderCreated') {
      await this.db.exec(
        'INSERT INTO order_view (id, customer, total_cents, status, last_event_at) VALUES (?, ?, ?, ?, ?)',
        [e.id, e.customer, Math.round(e.total * 100), 'new', e.at]
      );
    }
    if (e.kind === 'OrderCancelled') {
      await this.db.exec(
        'UPDATE order_view SET status=?, last_event_at=? WHERE id=?',
        ['cancelled', e.at, e.id]
      );
    }
  }
}

// ===== Eventual consistency =====
// Reads can lag writes by milliseconds-to-seconds.
// Strategies:
//  - Read-your-writes: pin user to write region or cache last command id
//  - Optimistic UI: render the predicted state immediately, reconcile later
//  - 'Job queued' UX: tell users when work is async

// ===== Patterns to internalise =====
// - Command and Query schemas evolve independently
// - Project to whatever shape the read needs (denorm, search index, ...)
// - Idempotent event handlers: replay must converge to the same state
// - Out-of-order tolerant projections (or strict ordering on the transport)

// ===== Pitfalls =====
// - Confusing CQRS with event sourcing; you can have CQRS without ES
// - Strong-consistency expectations from clients you did not warn
// - Projections that drift because a deploy missed an event version
// - Coupling the read store schema back to the aggregate -> the wins evaporate

Why it matters

CQRS is a scalpel, not a hammer. Reach for it when read and write shapes diverge, when read traffic dominates, or when you need many projections. The skeleton above is enough; resist the urge to bolt on event sourcing, sagas, and a message bus until the workload demands them.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// Command Query Responsibility Segregation.
// One model for writes (commands), another for reads (queries).
// Common with event sourcing in high-throughput systems.
Try it Yourself »

Exercise

CQRS separates…

from writes

Test yourself

Q1. CQRS splits…
Q2. CQRS pairs naturally with…
Q3. A risk is…

Discussion

Loading…