StackPractices
advanced Por Mathias Paulenko

Patrón CQRS

Separa los modelos de lectura y escritura para optimizar cada uno según su carga — cuándo CQRS merece la pena, cuándo es excesivo y cómo se combina con event sourcing.

Descripción general

CQRS (Command Query Responsibility Segregation) es un patrón arquitectónico que separa los modelos usados para escribir datos de los usados para leerlos. En lugar de un único modelo de datos manejando comandos y consultas, CQRS usa un Modelo de Comandos para escrituras y un Modelo de Consultas para lecturas, cada uno optimizado para su propia carga de trabajo.

La idea viene del principio CQS de Bertrand Meyer — un método debería cambiar estado (comando) o devolver datos (consulta), nunca ambas cosas. Greg Young llevó esa regla de nivel método a modelos completos alrededor de 2010, dándole el nombre CQRS. La implementación canónica lo combina con event sourcing: los eventos del lado de escritura son los datos fuente de las proyecciones del modelo de lectura.

Una advertencia por adelantado: CQRS es uno de los patrones más sobre-aplicados del software empresarial. El bliki de Martin Fowler lo dice claramente — pertenece a los bordes del sistema donde las necesidades de lectura y escritura divergen de verdad, no a todo el codebase.

Cuándo usarlo

Usa el Patrón CQRS cuando:

  • Las cargas de lectura y escritura tienen características de rendimiento muy diferentes o escalan de forma independiente
  • Necesitas varios modelos de lectura para los mismos datos (búsqueda, reportes, listados de API) sin complicar el modelo de escritura
  • Event sourcing ya está en uso, haciendo naturales las proyecciones de modelo de lectura
  • Equipos distintos son dueños de lecturas vs. escrituras, y el desacoplamiento reduce coordinación
  • Ejemplos: dashboards de analítica, catálogos de e-commerce, feeds de redes sociales, sistemas de reportes

Cuándo evitarlo

  • Apps CRUD simples — si lecturas y escrituras se parecen, un solo modelo es estrictamente más simple
  • Ratios lectura/escritura balanceados — el beneficio viene de necesidades divergentes, no del patrón en sí
  • Equipos pequeños — proyecciones, reconstrucciones y consumidores de eventos son superficie operativa real
  • Sistemas donde la consistencia eventual es inaceptable — un modelo de lectura con 200ms de lag puede ser inaceptable para usuarios que acaban de guardar algo
  • “Es un microservicio, así que necesita CQRS” — un CRUD por servicio no se convierte en CQRS por estar distribuido

Dos niveles de CQRS

El patrón tiene dos sabores distintos, y gran parte de la confusión viene de mezclarlos:

NivelQué se separaComplejidadCuándo basta
CQRS simpleModelos/objetos separados, misma base de datosBajaLa mayoría de apps que “hacen CQRS”
CQRS completoModelos Y almacenes separados, sincronización por eventosAltaEscala divergente, auditoría, múltiples proyecciones
flowchart diagram: subgraph WRITE[

Empieza con el nivel de la izquierda. El salto a almacenes separados es una puerta de un solo sentido — un almacén de escritura más N almacenes de lectura más un bus más proyecciones es la maquinaria event-driven completa, no solo “un poco más de arquitectura”.

Solución

Python

from dataclasses import dataclass, field
from typing import List, Dict
from datetime import datetime

# Modelo de Escritura (lado de comandos)
@dataclass
class Order:
    order_id: str
    customer_id: str
    items: List[dict] = field(default_factory=list)
    status: str = "pending"
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())

class OrderCommandHandler:
    def __init__(self):
        self.orders: Dict[str, Order] = {}

    def create_order(self, order_id: str, customer_id: str) -> Order:
        order = Order(order_id=order_id, customer_id=customer_id)
        self.orders[order_id] = order
        return order

    def add_item(self, order_id: str, product: str, qty: int, price: float):
        order = self.orders[order_id]
        order.items.append({"product": product, "qty": qty, "price": price})

    def confirm(self, order_id: str):
        self.orders[order_id].status = "confirmed"

# Modelo de Lectura (lado de consultas) — DTO desnormalizado optimizado para lecturas
@dataclass
class OrderSummary:
    order_id: str
    customer_id: str
    total: float
    item_count: int
    status: str

class OrderQueryHandler:
    def __init__(self, command_store: Dict[str, Order]):
        self.command_store = command_store

    def get_summary(self, order_id: str) -> OrderSummary:
        order = self.command_store[order_id]
        total = sum(i["qty"] * i["price"] for i in order.items)
        return OrderSummary(
            order_id=order.order_id,
            customer_id=order.customer_id,
            total=total,
            item_count=len(order.items),
            status=order.status
        )

    def list_by_customer(self, customer_id: str) -> List[OrderSummary]:
        return [
            self.get_summary(o.order_id)
            for o in self.command_store.values()
            if o.customer_id == customer_id
        ]

# Uso
commands = OrderCommandHandler()
commands.create_order("ORD-1", "CUST-1")
commands.add_item("ORD-1", "Laptop", 1, 999.99)
commands.add_item("ORD-1", "Mouse", 2, 29.99)
commands.confirm("ORD-1")

queries = OrderQueryHandler(commands.orders)
summary = queries.get_summary("ORD-1")
print(f"Pedido {summary.order_id}: ${summary.total:.2f} ({summary.item_count} artículos)")

JavaScript

// Modelo de Escritura (lado de comandos)
class OrderCommandHandler {
  constructor() {
    this.orders = new Map();
  }

  createOrder(orderId, customerId) {
    this.orders.set(orderId, {
      orderId, customerId, items: [], status: "pending",
      createdAt: new Date().toISOString()
    });
  }

  addItem(orderId, product, qty, price) {
    this.orders.get(orderId).items.push({ product, qty, price });
  }

  confirm(orderId) {
    this.orders.get(orderId).status = "confirmed";
  }
}

// Modelo de Lectura (lado de consultas)
class OrderQueryHandler {
  constructor(commandStore) {
    this.store = commandStore;
  }

  getSummary(orderId) {
    const order = this.store.get(orderId);
    const total = order.items.reduce((sum, i) => sum + i.qty * i.price, 0);
    return {
      orderId: order.orderId,
      customerId: order.customerId,
      total,
      itemCount: order.items.length,
      status: order.status
    };
  }

  listByCustomer(customerId) {
    return Array.from(this.store.values())
      .filter(o => o.customerId === customerId)
      .map(o => this.getSummary(o.orderId));
  }
}

// Uso
const commands = new OrderCommandHandler();
commands.createOrder("ORD-1", "CUST-1");
commands.addItem("ORD-1", "Laptop", 1, 999.99);
commands.confirm("ORD-1");

const queries = new OrderQueryHandler(commands.orders);
console.log(queries.getSummary("ORD-1"));

Java

import java.util.*;

// Modelo de Escritura — la entidad de dominio exige invariantes
class Order {
    String orderId;
    String customerId;
    List<Item> items = new ArrayList<>();
    String status = "pending";
    String createdAt = new Date().toString();

    record Item(String product, int qty, double price) {}

    void confirm() {
        if (items.isEmpty()) throw new IllegalStateException("No se puede confirmar un pedido vacío");
        this.status = "confirmed";
    }
}

class OrderCommandHandler {
    private final Map<String, Order> orders = new HashMap<>();

    public void createOrder(String orderId, String customerId) {
        Order o = new Order();
        o.orderId = orderId;
        o.customerId = customerId;
        orders.put(orderId, o);
    }

    public void addItem(String orderId, String product, int qty, double price) {
        orders.get(orderId).items.add(new Order.Item(product, qty, price));
    }

    public void confirm(String orderId) {
        orders.get(orderId).confirm();
    }

    public Map<String, Order> getStore() { return orders; }
}

// Modelo de Lectura — DTO plano, sin comportamiento
class OrderSummary {
    public String orderId;
    public String customerId;
    public double total;
    public int itemCount;
    public String status;
}

class OrderQueryHandler {
    private final Map<String, Order> store;

    public OrderQueryHandler(Map<String, Order> store) {
        this.store = store;
    }

    public OrderSummary getSummary(String orderId) {
        Order o = store.get(orderId);
        OrderSummary s = new OrderSummary();
        s.orderId = o.orderId;
        s.customerId = o.customerId;
        s.total = o.items.stream().mapToDouble(i -> i.qty() * i.price()).sum();
        s.itemCount = o.items.size();
        s.status = o.status;
        return s;
    }
}

// Uso — archivo: CqrsDemo.java
public class CqrsDemo {
    public static void main(String[] args) {
        OrderCommandHandler commands = new OrderCommandHandler();
        commands.createOrder("ORD-1", "CUST-1");
        commands.addItem("ORD-1", "Laptop", 1, 999.99);
        commands.confirm("ORD-1");

        OrderQueryHandler queries = new OrderQueryHandler(commands.getStore());
        System.out.println(queries.getSummary("ORD-1").total);
    }
}

Explicación

CQRS separa dos preocupaciones que un único modelo fuerza a juntar:

  • Modelo de Comandos: optimizado para escrituras — valida reglas de negocio, mantiene invariantes, procesa cambios de estado. Suele mapear de cerca al dominio.
  • Modelo de Consultas: optimizado para lecturas — desnormalizado, conformado por lo que la UI o el consumidor de API necesita, a menudo almacenado en una base de datos distinta (Elasticsearch para búsqueda, Redis para lookups rápidos).

Los dos modelos se sincronizan sincrónicamente (misma transacción — CQRS simple) o asincrónicamente (proyecciones por eventos — CQRS completo). En la variante async, las escrituras publican eventos y los manejadores de proyección reconstruyen el modelo de lectura:

// Manejador de proyección — reconstruye el modelo de lectura desde eventos
class OrderProjectionHandler {
  constructor(private readDb: ReadDatabase) {}

  async handle(event: OrderEvent): Promise<void> {
    switch (event.type) {
      case 'OrderCreated':
        await this.readDb.orders.insert({
          orderId: event.orderId,
          customerId: event.customerId,
          total: event.items.reduce((s, i) => s + i.price * i.quantity, 0),
          status: 'pending',
          createdAt: event.timestamp
        });
        break;
      case 'OrderConfirmed':
        await this.readDb.orders.update(
          { orderId: event.orderId },
          { status: 'confirmed', confirmedAt: event.timestamp }
        );
        break;
    }
  }
}

Modelos de consistencia

Elegir el nivel de sincronía es la decisión arquitectónica real:

ModeloCómoTrade-off
FuerteLectura y escritura en la misma transacciónSimple, pero derrota el escalado independiente
EventualEl modelo de lectura se actualiza asíncronamenteHueco de latencia — los usuarios pueden no ver sus propios cambios
Read-your-writesEnruta lecturas recientes al modelo de escritura temporalmenteMejor UX, añade complejidad de enrutado

Read-your-writes es la respuesta habitual al problema del refresh: tras una escritura, sirve las lecturas de ese usuario desde el almacén de escritura unos segundos mientras la proyección se pone al día.

Variantes

VarianteDescripciónCaso de uso
Base de datos únicaModelos separados, misma base de datosCQRS simple; menor complejidad
Doble base de datosEscritura a SQL, lectura de NoSQL/búsquedaConsultas complejas; alta escala de lectura
Event Sourcing + CQRSLos eventos son la fuente de verdad; los modelos de lectura son proyeccionesTrazas de auditoría; consultas temporales
Segregación de APIEndpoints REST/GraphQL separados para comandos y consultasMicroservicios; límites de equipo

Las elecciones de almacenamiento del modelo de lectura dependen más de la forma de la consulta que de preferencias:

Modelo de lecturaMejor paraAlmacenamiento
Vista materializadaAgregados pre-calculadosBase de datos documental
Índice de búsquedaTexto completo, búsqueda facetadaElasticsearch
Proyección de grafoConsultas de relacionesNeo4j
CachéLecturas calientes y efímerasRedis
Flujo de eventosAnalítica en tiempo realKafka/Kinesis

Lo que funciona

  • Empieza simple — separa modelos dentro de la misma base de datos antes de introducir almacenamiento dual
  • Usa consistencia eventual para modelos de lectura cuando las proyecciones asíncronas sean aceptables
  • Versiona tus modelos de lectura cuando cambie el esquema de consulta — una reconstrucción es más barata que una migración
  • Monitorea el lag de proyección — los modelos de lectura que se quedan atrás erosionan la confianza del usuario en silencio
  • Mantén los comandos pequeños y enfocados — un comando debería hacer una sola cosa
  • Haz los consumidores idempotentes — los eventos re-reproducidos golpearán tus proyecciones más de una vez

Errores comunes

  • Separación prematura — añadir CQRS a un CRUD simple compra complejidad, nada más. Es el fallo de CQRS más común.
  • Dejar que el modelo de lectura evada reglas de negocio — la validación pertenece a los comandos; un atajo del lado de lectura sobre un invariante corrompe el lado de escritura después.
  • Ignorar el bug del refresh — el usuario guarda, pulsa F5, no ve el cambio (lag de proyección). Sin read-your-writes u optimistic UI, los usuarios lo reportan como bug.
  • Explosión de modelos de lectura — una proyección por endpoint convierte el codebase en un proyecto de mantenimiento de proyecciones. Consolida formas.
  • Intentar hacer consistentes almacenes separados — eso es una transacción distribuida, exactamente lo que CQRS existe para evitar. Acepta consistencia eventual o quédate en un almacén.
  • Olvidar las reconstrucciones — la lógica de proyección cambia; si no puedes re-producir eventos (o reconstruir desde snapshots), el modelo de lectura no se puede arreglar.

Ejemplo del mundo real — Pedidos de e-commerce

Un sistema de pedidos en TypeScript sobre Kafka + Elasticsearch, con read-your-writes:

Escritura: POST /api/orders -> CreateOrderCommand -> aggregate Order (PostgreSQL)
Lectura:   GET  /api/orders?q=laptop -> OrderQueryHandler -> Elasticsearch
Sincronía: Eventos OrderCreated/Confirmed -> topic Kafka -> consumidor de proyección

Flujo read-your-writes:
  1. Create devuelve el ID del pedido inmediatamente
  2. El frontend muestra el pedido nuevo desde estado local (optimista)
  3. Poll a GET /api/orders/:id hasta que la proyección aterriza (~50-200ms de lag)
  4. Timeout 5s → fallback al modelo de escritura

Dimensionado típico:
  Escritura:  2 PostgreSQL (primario + réplica), p95 < 50ms
  Lectura:   3 nodos Elasticsearch, p95 < 20ms
  Bus:        Kafka, 12 particiones
  Lag:        < 500ms

Aquí el patrón se paga solo porque búsqueda/filtros pegan en Elasticsearch — una consulta que el modelo de escritura no podría servir bien — mientras el aggregate mantiene los invariantes en Postgres.

Solución de problemas

  • El modelo de lectura muestra datos viejos: lag de proyección o un consumidor muerto — revisa los offsets del consumer group y los logs del manejador de proyección.
  • El modelo de lectura se desvió y ya no es confiable: re-produce eventos (para consumidores → trunca almacén de lectura → replay). En almacenes grandes, corre una proyección en sombra junto a la actual, verifica, y cambia.
  • “El usuario guardó pero no lo ve”: es el bug del refresh — añade enrutado read-your-writes u optimistic UI, no sincronía más rápida.
  • Las proyecciones explotan en número: fusiona formas superpuestas; mantén un modelo de lectura por familia de consulta realmente distinta.
  • Comandos fallan en silencio tras la escritura: eventos emitidos pero consumidores caídos — añade DLQ/alertas en consumidores de proyección, y haz los eventos idempotentes.

Notas de producción

  • Sigue el lag de proyección como métrica de primer nivel (objetivo bajo ~500ms) — es el canario de todo el lado de lectura.
  • Versiona el esquema de cada proyección; un bump debería disparar una reconstrucción, no una migración a mano.
  • Los consumidores de proyección deben ser idempotentes — replays y reentregas son normales.
  • Mantén el modelo de escritura liviano; las preocupaciones del lado de lectura (índices, campos de búsqueda, formas desnormalizadas) pertenecen al lado de consultas.
  • Documenta cuándo dejar de usar CQRS en cada bounded context — es una decisión por contexto, no un default.

Puntos clave

  • CQRS separa escrituras (comandos, invariantes) de lecturas (consultas, formas desnormalizadas) — la división es de modelos, no necesariamente de almacenes.
  • Hay dos niveles: misma BD (barato, la mayoría de equipos) y doble almacén + eventos (caro, justificado solo por escala divergente).
  • La consistencia eventual es el precio; read-your-writes suele ser la mitigación.
  • El error más común es aplicarlo donde un CRUD bastaría — ajusta el patrón a la divergencia, no a la moda.

Lectura adicional

Preguntas frecuentes

¿CQRS requiere Event Sourcing?

No — los dos son independientes. Event sourcing se combina naturalmente porque los eventos son el input perfecto de proyecciones, pero CQRS funciona bien con un único almacén relacional: modelos separados + misma transacción.

¿Cómo se mantienen sincronizados los modelos de lectura y escritura?

En CQRS de una BD, en la misma transacción. En CQRS de doble almacén, escribe eventos y deja que los consumidores de proyección reconstruyan el modelo de lectura — acepta el lag o añade read-your-writes.

¿Cuándo debería usar bases de datos separadas?

Solo cuando los lados de lectura y escritura genuinamente no pueden compartir almacén — necesidades de escala distintas (búsqueda vs. ACID), requisitos de disponibilidad diferentes, o una forma de lectura que el modelo de escritura no puede servir (agregados, texto completo). Si no, el almacén extra es deuda operativa.

¿Qué se rompe si un modelo de lectura se corrompe?

Nada del lado de escritura — ese es el punto. La corrección es una reconstrucción: para consumidores, trunca el almacén de lectura, re-produce eventos. Si no puedes re-producir, no tienes event sourcing y las reconstrucciones se complican.

¿Cómo manejo el lag en modelos de lectura?

Tres opciones: enrutado read-your-writes para las lecturas del propio escritor, actualizaciones optimistas de UI que reflejan el cambio inmediatamente, o polling con verificación de versión hasta que la proyección aterrice.

¿CQRS sigue siendo relevante sin microservicios?

Sí — el patrón trata de separar modelos de lectura/escritura, que rinde frutos en un monolito también (una vista de reporte junto a un modelo de dominio). Los microservicios solo facilitan la separación de almacenes, no hacen el patrón más necesario.