Swiftorial Logo
Home
Swift Lessons
Tutorials
Learn More
Career
Resources

Command Query Responsibility Segregation (CQRS) Architecture

Introduction to CQRS

Command Query Responsibility Segregation (CQRS) is a pattern that separates the responsibilities of reading and writing data in an application. By maintaining distinct models for Commands (write operations that modify state) and Queries (read operations that retrieve state), CQRS enables better scalability, performance, and clearer domain modeling. This approach is particularly valuable in complex domains where read and write workloads have different requirements or when implementing event sourcing.

CQRS separates read and write operations at the architectural level, allowing independent optimization of each path.

CQRS Architecture Diagram

The diagram visualizes the CQRS pattern with clear separation between command and query flows. A Client sends Commands (yellow) to the Command Handler which updates the Write Model (event store), while Queries (blue) are served from the optimized Read Model. The Event Processor synchronizes data between models asynchronously (green). Critical components are color-coded for clarity.

graph TD A[Client] -->|All Requests| B[API Gateway] B -->|Routes Commands| C[Command Handler] B -->|Routes Queries| D[Query Handler] C -->|Persists Events| E[(Event Store)] E -->|Publishes Events| F[Event Processor] F -->|Updates| G[(Read Database)] D -->|Reads From| G subgraph Client_Layer["Client Layer"] A end subgraph Gateway_Layer["Gateway Layer"] B end subgraph Command_Flow["Command Flow"] C E end subgraph Query_Flow["Query Flow"] D G end subgraph Synchronization["Synchronization"] F end classDef client fill:#ffeb3b,stroke:#ffeb3b,stroke-width:2px,rx:10,ry:10; classDef gateway fill:#ff6f61,stroke:#ff6f61,stroke-width:2px,rx:5,ry:5; classDef command fill:#f39c12,stroke:#f39c12,stroke-width:2px,rx:5,ry:5; classDef query fill:#3498db,stroke:#3498db,stroke-width:2px,rx:5,ry:5; classDef event fill:#9b59b6,stroke:#9b59b6,stroke-width:2px,rx:5,ry:5; classDef sync fill:#2ecc71,stroke:#2ecc71,stroke-width:2px,rx:5,ry:5; classDef storage fill:#e67e22,stroke:#e67e22,stroke-width:2px,rx:5,ry:5; class A client; class B gateway; class C command; class D query; class E storage; class F sync; class G storage; linkStyle 0 stroke:#ffeb3b,stroke-width:2.5px; linkStyle 1 stroke:#ff6f61,stroke-width:2.5px; linkStyle 2 stroke:#ff6f61,stroke-width:2.5px; linkStyle 3 stroke:#f39c12,stroke-width:2.5px; linkStyle 4 stroke:#9b59b6,stroke-width:2.5px,stroke-dasharray:3,3; linkStyle 5 stroke:#2ecc71,stroke-width:2.5px; linkStyle 6 stroke:#3498db,stroke-width:2.5px;
All client requests flow through the API Gateway which routes them to the appropriate handler, maintaining clear separation of concerns and enabling centralized cross-cutting concerns like authentication and rate limiting.

Key Components

The core components of a CQRS architecture include:

  • Command Side:
    • Command: Immutable request to perform an action (e.g., "PlaceOrder")
    • Command Handler: Validates and executes commands against domain model
    • Event Store: Append-only log of all state changes as domain events
  • Query Side:
    • Query: Request for data without side effects (e.g., "GetOrderStatus")
    • Query Handler: Retrieves data from optimized read models
    • Read Database: Denormalized, query-optimized data store (SQL, MongoDB, etc.)
  • Synchronization:
    • Event Processor: Subscribes to events and updates read models
    • Projections: Transformations that convert events to read model formats

Benefits of CQRS

  • Performance Optimization:
    • Read models can use denormalized schemas optimized for queries
    • Write models avoid query-related overhead
  • Scalability:
    • Read and write components scale independently
    • Read databases can be replicated geographically
  • Domain Clarity:
    • Separate models prevent query concerns from complicating domain logic
    • Explicit modeling of command-side business rules
  • Flexibility:
    • Different storage technologies for read and write paths
    • Easier to evolve read models without affecting writes

Implementation Considerations

Implementing CQRS effectively requires addressing several architectural concerns:

  • Eventual Consistency:
    • Read models will be temporarily stale after writes
    • Design UIs to handle this (optimistic updates, loading states)
  • Event Processing:
    • Implement idempotent event handlers to handle retries
    • Consider event ordering and exactly-once processing
  • Complexity Management:
    • Only use CQRS for bounded contexts where benefits outweigh costs
    • Start simple and add complexity as needed
  • Monitoring:
    • Track event processing latency between write and read models
    • Monitor for projection failures
  • Testing:
    • Test command validation and event emission
    • Verify read model projections against sample events
CQRS works particularly well when combined with Event Sourcing, but can also be implemented with traditional CRUD updates to the write database.

Example: CQRS with Event Sourcing

Below is a TypeScript implementation demonstrating CQRS with event sourcing:

// Command Types
type Command = 
  | { type: 'OpenAccount'; accountId: string; owner: string }
  | { type: 'Deposit'; accountId: string; amount: number };

// Event Types
type Event =
  | { type: 'AccountOpened'; accountId: string; owner: string }
  | { type: 'Deposited'; accountId: string; amount: number };

// Write Model (Command Side)
class AccountAggregate {
  private events: Event[] = [];

  execute(command: Command): Event[] {
    switch (command.type) {
      case 'OpenAccount':
        const openedEvent: Event = { 
          type: 'AccountOpened', 
          accountId: command.accountId, 
          owner: command.owner 
        };
        this.events.push(openedEvent);
        return [openedEvent];
      case 'Deposit':
        const depositedEvent: Event = { 
          type: 'Deposited', 
          accountId: command.accountId, 
          amount: command.amount 
        };
        this.events.push(depositedEvent);
        return [depositedEvent];
    }
  }
}

// Read Model (Query Side)
class AccountView {
  private accounts: Map = new Map();

  applyEvent(event: Event) {
    switch (event.type) {
      case 'AccountOpened':
        this.accounts.set(event.accountId, { 
          owner: event.owner, 
          balance: 0 
        });
        break;
      case 'Deposited':
        const account = this.accounts.get(event.accountId);
        if (account) {
          account.balance += event.amount;
        }
        break;
    }
  }

  getAccount(accountId: string) {
    return this.accounts.get(accountId);
  }
}

// Usage
const aggregate = new AccountAggregate();
const view = new AccountView();

// Process commands
const events1 = aggregate.execute({ 
  type: 'OpenAccount', 
  accountId: '123', 
  owner: 'Alice' 
});
events1.forEach(e => view.applyEvent(e));

const events2 = aggregate.execute({ 
  type: 'Deposit', 
  accountId: '123', 
  amount: 100 
});
events2.forEach(e => view.applyEvent(e));

// Query read model
console.log(view.getAccount('123')); // { owner: 'Alice', balance: 100 }
                

Example: Projection for Read Model

Creating a read model projection from events:

// Projection to create customer order history view
class CustomerOrderHistoryProjection {
  private history: Map> = new Map();

  handleEvent(event: Event) {
    if (event.type === 'OrderPlaced') {
      if (!this.history.has(event.customerId)) {
        this.history.set(event.customerId, []);
      }
      this.history.get(event.customerId)!.push({
        orderId: event.orderId,
        amount: event.amount
      });
    }
  }

  getCustomerHistory(customerId: string) {
    return this.history.get(customerId) || [];
  }
}

// Example usage with event store
const projection = new CustomerOrderHistoryProjection();
eventStore.getEvents().forEach(event => projection.handleEvent(event));

// Query optimized read model
console.log(projection.getCustomerHistory('cust-456'));