DEV Community

Cover image for CQRS: separar comandos y consultas para escalar un sistema
lu1tr0n
lu1tr0n

Posted on Originally published at elsolitario.org

CQRS: separar comandos y consultas para escalar un sistema

Una aplicación que factura pedidos y a la vez genera reportes de ventas termina peleando por los mismos recursos de la base de datos. Ese cuello de botella tiene nombre técnico: CQRS. La solución consiste en separar el modelo que escribe del modelo que lee, cada uno optimizado para su propia tarea.

El patrón separa dos responsabilidades que casi siempre viven juntas en el mismo modelo, escribir datos con comandos y leerlos con consultas. Cuando una aplicación crece, esa mezcla empieza a costar rendimiento, código enredado y consultas SQL cada vez más complejas.

TL;DR

  • CQRS separa el modelo de escritura (comandos) del modelo de lectura (consultas), cada uno con su propio esquema.
  • Un comando cambia estado y no devuelve datos de negocio; una consulta lee datos y nunca modifica nada.
  • Event Sourcing guarda cada cambio como un evento inmutable en vez de sobrescribir filas, y el estado se reconstruye reproduciendo esos eventos.
  • La vista de lectura se actualiza de forma asíncrona, así que existe una ventana de consistencia eventual que hay que diseñar, no ignorar.
  • MediatR en .NET, Axon Framework en Java y EventStoreDB son las herramientas de referencia para implementar el patrón sin construir el mecanismo desde cero.
  • Separar comandos de consultas permite escalar cada lado de forma independiente, con más réplicas de lectura sin tocar el camino de escritura.
  • CQRS sin Event Sourcing es perfectamente válido: solo separar las tablas de lectura y escritura ya resuelve el problema de rendimiento más común.

Qué es CQRS y por qué importa separar lecturas de escrituras

CQRS significa Command Query Responsibility Segregation, un término que acuñó Greg Young y que Martin Fowler documentó en detalle en su blog técnico. La idea central es sencilla. Un comando modifica el estado del sistema; una consulta lo lee. Ninguna operación debería hacer ambas cosas a la vez.

En la mayoría de aplicaciones CRUD (Create, Read, Update, Delete) ambas operaciones comparten el mismo modelo de datos. Una tabla de pedidos sirve tanto para insertar una compra nueva como para generar el reporte mensual de ventas. Funciona bien con tráfico bajo, pero cuando el reporte necesita joins pesados sobre la misma tabla que recibe miles de escrituras por minuto, ambos lados compiten por bloqueos y por caché.

CQRS ataca ese problema separando el modelo de escritura del modelo de lectura. Cada uno puede vivir en un motor de base de datos distinto, con un esquema pensado para su propósito: el de escritura normalizado y transaccional, el de lectura desnormalizado y rápido de consultar.

Cómo funciona: comandos, consultas y el flujo de datos

Un comando expresa una intención, como CrearPedido, CancelarSuscripcion o ActualizarInventario. Se procesa contra el modelo de escritura, valida las reglas de negocio y persiste el cambio. Nunca devuelve datos de negocio, como mucho un identificador o un código de estado.

Una consulta pide datos, como ObtenerPedidosDelCliente o ListarProductosConStock. Lee del modelo de lectura, que suele ser una vista ya calculada o una tabla desnormalizada pensada para responder rápido, sin tocar la lógica de negocio ni las reglas de validación.

Entre ambos modelos hay un mecanismo de sincronización. Cuando el modelo de escritura confirma un cambio, publica un evento (PedidoCreado, InventarioActualizado) que un proceso independiente escucha y usa para actualizar la vista de lectura correspondiente.

flowchart TD
    A["Cliente"] --> B["Comando: CrearPedido"]
    A --> C["Consulta: ObtenerPedidos"]
    B --> D["Modelo de escritura"]
    D --> E["Evento: PedidoCreado"]
    E --> F["Proyector"]
    F --> G["Modelo de lectura"]
    C --> G
Enter fullscreen mode Exit fullscreen mode

El diagrama muestra el punto clave del patrón. El cliente nunca lee directo del modelo de escritura. Toda consulta va al modelo de lectura, que un proceso llamado proyector mantiene actualizado a partir de los eventos que emite el lado de escritura.

Greg Young presentó CQRS como evolución del Command Query Separation de Bertrand Meyer.

Ejemplos prácticos: de CRUD tradicional a CQRS

El primer ejemplo es el punto de partida típico: un servicio que junta lectura y escritura en la misma clase, sobre la misma tabla.

// crud-tradicional.ts
class PedidoService {
  async crear(clienteId: string, items: Item[]) {
    const pedido = { id: crypto.randomUUID(), clienteId, items, estado: "creado" };
    await db.query(
      "INSERT INTO pedidos (id, cliente_id, items, estado) VALUES ($1, $2, $3, $4)",
      [pedido.id, pedido.clienteId, JSON.stringify(pedido.items), pedido.estado]
    );
    return pedido.id;
  }

  async listarPorCliente(clienteId: string) {
    const { rows } = await db.query(
      "SELECT * FROM pedidos WHERE cliente_id = $1 ORDER BY creado_en DESC",
      [clienteId]
    );
    return rows;
  }
}
Enter fullscreen mode Exit fullscreen mode

Este código funciona hasta que listarPorCliente empieza a incluir joins con productos, envíos y facturación. Cada consulta nueva compite por la misma tabla que recibe los INSERT del checkout en horario pico.

El segundo ejemplo separa comandos de consultas en clases distintas, cada una con su propia conexión a base de datos.

// comandos/crear-pedido.ts
export class CrearPedidoHandler {
  async handle(cmd: { clienteId: string; items: Item[] }) {
    const pedido = new Pedido(cmd.clienteId, cmd.items);
    await writeDb.query(
      "INSERT INTO pedidos_write (id, cliente_id, items, estado) VALUES ($1, $2, $3, $4)",
      [pedido.id, pedido.clienteId, JSON.stringify(pedido.items), "creado"]
    );
    await eventBus.publish("PedidoCreado", { pedidoId: pedido.id, clienteId: cmd.clienteId });
    return pedido.id;
  }
}

// consultas/listar-pedidos.ts
export class ListarPedidosHandler {
  async handle(query: { clienteId: string }) {
    const { rows } = await readDb.query(
      "SELECT * FROM pedidos_read WHERE cliente_id = $1 ORDER BY creado_en DESC",
      [query.clienteId]
    );
    return rows;
  }
}

// proyeccion/actualizar-vista.ts
eventBus.subscribe("PedidoCreado", async (evento) => {
  await readDb.query(
    "INSERT INTO pedidos_read (id, cliente_id, resumen, creado_en) VALUES ($1, $2, $3, now())",
    [evento.pedidoId, evento.clienteId, "Pedido nuevo"]
  );
});
Enter fullscreen mode Exit fullscreen mode

El comando escribe en pedidos_write y publica PedidoCreado. Un suscriptor independiente actualiza pedidos_read, la tabla que de verdad consulta la aplicación. Si mañana la vista de lectura necesita más columnas, se agregan sin tocar el modelo de escritura.

💡 Tip: no hace falta Event Sourcing para empezar. Separar solo las tablas de lectura y escritura, como en el ejemplo anterior, ya resuelve gran parte de los problemas de rendimiento sin la complejidad de guardar eventos.

El tercer ejemplo agrega Event Sourcing. En vez de guardar el estado final del pedido, se guarda cada evento y el estado se reconstruye reproduciéndolos en orden.

// event-store.ts
type Evento = { tipo: string; payload: any; version: number };

class PedidoAggregate {
  estado = "";
  items: Item[] = [];

  aplicar(evento: Evento) {
    switch (evento.tipo) {
      case "PedidoCreado":
        this.estado = "creado";
        this.items = evento.payload.items;
        break;
      case "PedidoConfirmado":
        this.estado = "confirmado";
        break;
      case "PedidoCancelado":
        this.estado = "cancelado";
        break;
    }
  }

  static reconstruir(eventos: Evento[]): PedidoAggregate {
    const pedido = new PedidoAggregate();
    for (const evento of eventos.sort((a, b) => a.version - b.version)) {
      pedido.aplicar(evento);
    }
    return pedido;
  }
}
Enter fullscreen mode Exit fullscreen mode

Para saber el estado actual de un pedido, el sistema no lee una fila, sino que reproduce todos sus eventos ordenados por versión con reconstruir. El resultado es el mismo estado, pero con historial completo de cada cambio, útil para auditoría y para depurar bugs de negocio meses después.

Cómo empezar paso a paso

La forma más simple de probar CQRS es sin Event Sourcing, con Node.js y dos conexiones PostgreSQL, aunque apunten a la misma instancia al inicio.

  • Instalar las dependencias base: npm install pg eventemitter2.
  • Crear dos pools de conexión, uno para escritura (writeDb) y otro para lectura (readDb).
  • Definir el bus de eventos con eventemitter2: const eventBus = new EventEmitter2();.
  • Escribir el handler de comando que inserta en la tabla de escritura y publica el evento, como en el ejemplo CrearPedidoHandler de arriba.
  • Escribir el proyector que escucha el evento y actualiza la tabla de lectura.
  • Exponer dos rutas HTTP separadas: POST /pedidos para el comando y GET /pedidos/:clienteId para la consulta, cada una llamando solo a su handler correspondiente.

Para verificar que la separación funciona de verdad, medí el tiempo de respuesta de GET /pedidos/:clienteId mientras el endpoint de comando recibe carga con una herramienta como autocannon. Si la lectura no se degrada cuando la escritura está bajo presión, la separación está cumpliendo su propósito.

Si ya trabajás en .NET, la ruta más directa es la librería MediatR: define IRequest para comandos y consultas, y un IRequestHandler por cada uno. En Java, Axon Framework agrega Event Sourcing y proyecciones listas para producción sin escribir el bus de eventos a mano.

Casos de uso reales

Un sistema bancario que registra transferencias como eventos inmutables, sin borrar ni sobrescribir un movimiento, es el ejemplo clásico de Event Sourcing. El saldo de una cuenta es la suma de todos sus eventos, y el historial completo sirve para auditoría regulatoria.

En e-commerce, el catálogo de productos suele leerse miles de veces más de lo que se escribe. Separar el modelo de lectura permite cachear agresivamente esa vista y reservar la capacidad de escritura para el checkout, que sí necesita transacciones estrictas.

En logística e inventario, cada movimiento de stock (entrada, salida, ajuste) funciona bien como comando independiente. La vista de lectura puede mostrar el stock disponible por bodega sin recalcular sumas en cada consulta, porque el proyector ya mantiene el total actualizado.

Los sistemas de colaboración en tiempo real, como los que usan CRDTs para editar documentos simultáneamente, también aplican variantes de CQRS. Cada edición es un comando, y la vista que ve cada usuario es una proyección reconstruida a partir de esos comandos.

EventStoreDB es una base de datos open source diseñada específicamente para Event Sourcing.

Errores comunes y buenas prácticas

El error más frecuente es aplicar CQRS a un CRUD simple que nunca tuvo problemas de rendimiento. Si una tabla recibe cien escrituras al día y las consultas tardan milisegundos, separar modelos solo agrega infraestructura sin beneficio real.

El segundo error es subestimar la consistencia eventual. Si un usuario crea un pedido y el frontend redirige de inmediato a la lista de pedidos, la proyección puede no haberse actualizado todavía y el pedido nuevo no aparece. La solución habitual es devolver el pedido recién creado desde la misma respuesta del comando, sin depender de una consulta inmediata al modelo de lectura.

⚠️ Ojo: nunca leas del modelo de escritura para mostrarle datos al usuario "por si la proyección todavía no llegó". Eso reintroduce el acoplamiento que CQRS existe para eliminar y termina con dos caminos de lectura que hay que mantener.

Un tercer gotcha aparece con colas de mensajes que garantizan entrega al menos una vez: el mismo evento puede llegar duplicado al proyector. Los handlers de proyección deben ser idempotentes, por ejemplo usando INSERT ... ON CONFLICT DO NOTHING con el id del evento como clave, para que procesar el mismo evento dos veces no duplique datos.

Cuando se usa Event Sourcing, cambiar la forma de un evento ya publicado rompe la reconstrucción de agregados viejos. La práctica estándar es versionar los eventos (PedidoCreado_v2) y mantener un manejador que sepa migrar los eventos antiguos al nuevo formato.

Comparativa con alternativas

OpciónCuándo usarlaVentajaLimitación

CRUD tradicionalApps pequeñas o con tráfico bajo y parejo entre lectura y escrituraSimplicidad, un solo modelo que mantenerNo escala bien cuando lecturas y escrituras compiten por los mismos recursos
CQRS sin Event SourcingLas consultas necesitan un esquema distinto al de escritura, pero no hace falta historial completoCada modelo se optimiza y escala por separadoRequiere sincronizar dos esquemas y aceptar consistencia eventual
CQRS con Event SourcingDominios que necesitan auditoría, historial completo o reconstruir estado en cualquier punto del tiempoHistorial inmutable, depuración de bugs de negocio con contexto completoMayor complejidad operativa: versionado de eventos, snapshots, replays

Profundizando: consistencia eventual y snapshots

Cuando un agregado acumula miles de eventos, reconstruirlo reproduciendo todos desde cero en cada lectura se vuelve lento. La solución habitual es el snapshot: guardar el estado calculado cada N eventos (por ejemplo, cada 100) y al reconstruir partir del snapshot más reciente en vez de desde el evento cero.

sequenceDiagram
    participant Cliente
    participant ComandoHandler
    participant EventStore
    participant Proyector
    participant ModeloLectura
    Cliente->>ComandoHandler: CrearPedido
    ComandoHandler->>EventStore: guardar PedidoCreado v1
    EventStore-->>Proyector: notifica PedidoCreado
    Proyector->>ModeloLectura: actualizar vista
    Note over EventStore,Proyector: la actualizacion es asincrona
    Cliente->>ModeloLectura: ObtenerPedidos
    ModeloLectura-->>Cliente: lista de pedidos
Enter fullscreen mode Exit fullscreen mode

El diagrama de secuencia muestra la ventana de consistencia eventual. Entre que EventStore guarda el evento y Proyector termina de actualizar ModeloLectura, una consulta inmediata puede no ver todavía el pedido nuevo.

flowchart LR
    A["Evento 1: PedidoCreado"] --> B["Evento 2: PedidoConfirmado"]
    B --> C["Evento 3: ItemAgregado"]
    C --> D["Snapshot en evento 100"]
    D --> E["Evento 101: PedidoEnviado"]
    E --> F["Estado actual reconstruido"]
Enter fullscreen mode Exit fullscreen mode

Reconstruir el estado actual ya no exige reproducir los cien eventos previos al snapshot, solo el snapshot más el puñado de eventos posteriores. Frameworks como Axon y EventStoreDB automatizan este mecanismo, pero implementarlo a mano es sencillo: guardar el estado serializado cada N eventos junto con el número de versión.

La forma de probar comandos también cambia. En vez de mockear la base de datos, un test típico de Event Sourcing sigue el patrón given-when-then: dado un conjunto de eventos previos, cuando se ejecuta un comando, entonces se espera que se publique un evento nuevo con un contenido específico. Las consultas, en cambio, se testean insertando filas directo en el modelo de lectura y verificando que el handler devuelva lo esperado, sin tocar el lado de escritura para nada.

💭 Clave: CQRS y Event Sourcing son dos patrones independientes que se complementan bien, pero adoptar uno no obliga a adoptar el otro. La mayoría de sistemas en producción usan CQRS sin guardar el historial completo de eventos.

📖 Resumen en Telegram: Ver resumen

Tu próximo paso: cloná un proyecto Node.js pequeño, separá una sola ruta en un handler de comando y uno de consulta con sus propias funciones de acceso a datos, y medí con autocannon si la lectura deja de degradarse bajo carga de escritura.

Preguntas frecuentes

¿CQRS siempre necesita Event Sourcing?

No. CQRS solo exige separar el modelo de escritura del modelo de lectura. Event Sourcing es una técnica adicional para guardar el historial completo de cambios, y muchos sistemas usan CQRS sin ella.

¿Qué problema resuelve CQRS que un ORM no resuelve?

Un ORM sigue usando el mismo esquema para leer y escribir. CQRS separa los esquemas, así que la vista de lectura puede desnormalizarse y optimizarse para consultas específicas sin afectar las reglas de validación del lado de escritura.

¿CQRS complica demasiado un proyecto pequeño?

Sí, en la mayoría de los casos. Si el proyecto no tiene un cuello de botella real entre lecturas y escrituras, agregar dos modelos y un mecanismo de sincronización solo suma código para mantener.

¿Qué pasa si el modelo de lectura se desactualiza?

Existe una ventana de consistencia eventual entre que el comando se confirma y la proyección se actualiza. Se maneja devolviendo el resultado del comando directamente al cliente, sin depender de una consulta inmediata al modelo de lectura.

¿Cómo se prueban los eventos duplicados en el proyector?

Guardando el id del evento como clave única en la tabla de lectura y usando una inserción que ignore conflictos, de forma que procesar el mismo evento dos veces no duplique el efecto sobre la vista.

¿CQRS es lo mismo que microservicios?

No. CQRS es un patrón de separación de responsabilidades dentro de un servicio o dominio. Puede aplicarse dentro de un monolito o dentro de un microservicio individual, sin depender de una arquitectura distribuida.

Referencias

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.

Top comments (0)