Skip to content
StackPractices
advanced Por Mathias Paulenko

CQRS — Segregación de Responsabilidades de Comandos y

Referencia Detallada de CQRS: separa los modelos de lectura y escritura para optimizar rendimiento, escalabilidad y autonomía de equipos en dominios complejos.

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Overview

CQRS (Command Query Responsibility Segregation) es un patrón arquitectónico que separa los modelos utilizados para escribir datos de los modelos utilizados para leer datos. En lugar de un único modelo que maneja tanto comandos (escrituras) como consultas (lecturas), CQRS los divide en caminos distintos optimizados para sus respectivos propósitos. Esta separación permite ajustar rendimiento, escalar independientemente y simplificar los modelos mentales para dominios complejos.

When to Use

  • For alternatives, see Event Sourcing — State as a Sequence of Events.

  • Las cargas de lectura y escritura tienen requisitos fundamentalmente diferentes

  • Necesitas múltiples modelos de lectura para los mismos datos (ej. búsqueda, reportes, APIs)

  • Diferentes equipos poseen las lecturas vs las escrituras

  • Ya usas event sourcing (pareamiento natural)

  • Necesitas escalar lecturas y escrituras de forma independiente

When NOT to Use

  • CRUD simple con patrones de lectura/escritura similares
  • Equipos pequeños sin capacidad operacional para la complejidad adicional
  • Sistemas donde la consistencia eventual es inaceptable en todas partes

Conceptos Core

Comandos

Los comandos representan intenciones de cambiar estado. Se nombran en imperativo y deben fallar rápido si la validación falla.

interface CreateOrderCommand {
  customerId: string;
  items: OrderItem[];
  shippingAddress: Address;
}

Consultas

Las consultas retornan datos sin efectos secundarios. Se moldean por las necesidades de la UI o consumidor, no por el modelo de dominio.

interface OrderSummaryQuery {
  customerId: string;
  status?: OrderStatus;
  page: number;
  pageSize: number;
}

interface OrderSummary {
  orderId: string;
  total: Money;
  status: OrderStatus;
  placedAt: Date;
}

Modelo de Escritura

Optimizado para consistencia, validación y reglas de negocio. Generalmente mapea estrechamente al modelo de dominio.

Modelo de Lectura

Optimizado para rendimiento de consultas. Generalmente desnormalizado, proyectado y almacenado en una base de datos diferente.

CQRS Simple (Base de Datos Única)

┌─────────────┐      ┌──────────────┐
│   Comando   │─────▶│  Modelo Escr │
│   Handler   │      │   (ORM)      │
└─────────────┘      └──────┬───────┘

                     ┌──────┴───────┐
                     │   Base de    │
                     │    Datos     │
                     └──────┬───────┘

┌─────────────┐      ┌──────┴───────┐
│   Consulta  │─────▶│  Modelo Lect │
│   Handler   │      │  (DTO/Vista) │
└─────────────┘      └──────────────┘
// Lado escritura — modelo de dominio completo
class Order {
  private items: OrderItem[] = [];
  private status: OrderStatus = OrderStatus.PENDING;

  addItem(product: Product, quantity: number): void {
    if (quantity <= 0) throw new DomainError('Cantidad debe ser positiva');
    this.items.push(new OrderItem(product, quantity));
  }

  confirm(): void {
    if (this.items.length === 0) throw new DomainError('No se puede confirmar orden vacía');
    this.status = OrderStatus.CONFIRMED;
  }
}

// Lado lectura — DTO plano optimizado para listados
interface OrderListItem {
  orderId: string;
  customerName: string;
  totalAmount: number;
  itemCount: number;
  status: string;
  placedAt: string;
}

CQRS Avanzado (Almacenes Separados)

┌─────────────┐      ┌──────────────┐      ┌─────────────┐
│   Comando   │─────▶│  Modelo Escr │─────▶│  Event Store│
│   Handler   │      │  (Aggregate) │      │  (Eventos)  │
└─────────────┘      └──────────────┘      └──────┬────┘

                                              ┌────┴────┐
                                              │  Event  │
                                              │  Bus    │
                                              └───┬─────┘

┌─────────────┐      ┌──────────────┐      ┌─────┴────┐
│   Consulta  │─────▶│  Modelo Lect │◀─────│ Projection│
│   Handler   │      │   (NoSQL)    │      │  Handler  │
└─────────────┘      └──────────────┘      └───────────┘

Ejemplo de Proyección

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((sum, i) => sum + 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;
    }
  }
}

Patrones de Optimización de Modelo de Lectura

PatrónCaso de UsoAlmacenamiento
Vista MaterializadaAgregados pre-computadosDocument DB
Índice de BúsquedaBúsqueda full-textElasticsearch
Proyección de GrafoConsultas de relacionesNeo4j
CachéDatos calientesRedis
Stream de EventosAnalítica en tiempo realKafka/Kinesis

Modelos de Consistencia

  • Consistencia fuerte — leer y escribir desde la misma transacción (CQRS simple)
  • Consistencia eventual — modelo de lectura actualiza asincrónicamente (almacenes separados)
  • Lee-tus-escrituras — enruta lecturas recientes al modelo de escritura temporalmente

Errores Comunes

  • Separación prematura — agregar CQRS a CRUD simple añade complejidad sin beneficio
  • Bugs de consistencia eventual — usuarios refrescan y no ven sus propias escrituras
  • Explosión de modelos de lectura — mantener demasiadas proyecciones para cada caso de uso
  • Infierno de transacciones distribuidas — intentar hacer almacenes separados fuertemente consistentes

FAQ

¿CQRS requiere Event Sourcing? No. Puedes usar CQRS con una base de datos relacional para lecturas y escrituras, o con bases de datos separadas. Event sourcing es un compañero natural pero no requerido.

¿Cómo manejo el lag en modelos de lectura? Usa el patrón lee-tus-escrituras, actualizaciones optimistas de UI, o polling con verificación de versión.

¿Puedo usar CQRS con microservicios? Sí. Cada servicio puede tener su propia separación lectura/escritura. Ten cuidado con consultas entre servicios — prefiere composición de API o vistas materializadas.

¿Cómo empiezo con esto en un proyecto existente?

Empieza con una parte pequeña y aislada de tu codebase. Aplica los conceptos de esta guía a un módulo o servicio. Mide el impacto, luego expande a otras áreas.

¿Qué herramientas necesito?

Las herramientas mencionadas throughout esta guía se listan en cada sección. La mayoría son open-source y ampliamente adoptadas. Consulta los recursos relacionados para instrucciones de setup.

¿Cómo mido el éxito después de implementar esto?

Define métricas claras antes de empezar: benchmarks de rendimiento, tasas de error o indicadores de mantenibilidad. Compara antes y después. Itera basándote en datos, no en suposiciones.

Temas Avanzados

Escenario Detallado: CQRS para Sistema de Ordenes E-commerce

Sistema: Gestion de ordenes e-commerce (TypeScript + Node.js)
Modelo escritura: PostgreSQL (normalizado, ACID)
Modelo lectura: Elasticsearch (desnormalizado, optimizado para busqueda)
Event bus: Kafka (proyecciones event-driven)

Lado escritura (command handlers):
  POST /api/orders -> CreateOrderCommand -> Order aggregate -> OrderCreated event
  PUT /api/orders/:id/confirm -> ConfirmOrderCommand -> Order aggregate -> OrderConfirmed event
  PUT /api/orders/:id/cancel -> CancelOrderCommand -> Order aggregate -> OrderCancelled event

  // Command handler
  class CreateOrderHandler {
    async handle(cmd: CreateOrderCommand): Promise<OrderId> {
      const order = Order.create(cmd.customerId, cmd.items);
      await this.orderRepo.save(order); // PostgreSQL
      // Eventos despachados despues de guardar
      return order.id;
    }
  }

  // Aggregate aplica invariantes
  class Order extends AggregateRoot {
    static create(customerId: string, items: OrderItem[]): Order {
      if (items.length === 0) throw new Error("Orden vacia");
      if (items.length > 50) throw new Error("Max 50 items");
      const order = new Order(OrderId.generate(), customerId);
      order.status = OrderStatus.PENDING;
      order.items = items;
      order.total = items.reduce((s, i) => s + i.price * i.qty, 0);
      order.raiseEvent(new OrderCreated(order.id, customerId, items, order.total));
      return order;
    }
  }

Flujo de eventos:
  OrderCreated event -> Kafka topic orders.events -> Projection handler
  OrderConfirmed event -> Kafka topic orders.events -> Projection handler
  OrderCancelled event -> Kafka topic orders.events -> Projection handler

Lado lectura (projection handlers):
  class OrderProjection {
    async handle(event: DomainEvent): Promise<void> {
      switch (event.type) {
        case "OrderCreated":
          await this.es.index({
            index: "orders",
            id: event.orderId,
            body: {
              orderId: event.orderId,
              customerId: event.customerId,
              status: "pending",
              total: event.total,
              itemCount: event.items.length,
              items: event.items, // Desnormalizado para lectura
              createdAt: event.timestamp
            }
          });
          break;
        case "OrderConfirmed":
          await this.es.update({
            index: "orders",
            id: event.orderId,
            body: { doc: { status: "confirmed", confirmedAt: event.timestamp } }
          });
          break;
        case "OrderCancelled":
          await this.es.update({
            index: "orders",
            id: event.orderId,
            body: { doc: { status: "cancelled", cancelledAt: event.timestamp } }
          });
          break;
      }
    }
  }

Lado lectura (query handlers):
  GET /api/orders/search?q=laptop -> query Elasticsearch
  GET /api/orders?status=pending&customerId=123 -> filter Elasticsearch
  GET /api/orders/:id -> Elasticsearch GET por ID

  class OrderQueryHandler {
    async searchOrders(query: string, page: number): Promise<OrderListItem[]> {
      const result = await this.es.search({
        index: "orders",
        body: {
          query: { multi_match: { query, fields: ["items.name", "orderId"] } },
          from: (page - 1) * 20,
          size: 20
        }
      });
      return result.hits.hits.map(h => h._source);
    }
  }

Consistencia lee-tus-escrituras:
  - Cuando un usuario crea una orden, retorna el order ID inmediatamente
  - Frontend muestra orden optimista en la lista (desde estado local)
  - Polling GET /api/orders/:id hasta que el status aparezca en Elasticsearch
  - Lag tipico: 50-200ms (Kafka + proyeccion)
  - Timeout: si no visible en 5s, fallback a query al modelo de escritura

Escalado:
  Lado escritura: 2 instancias PostgreSQL (master + replica)
  Lado lectura: 3 nodos Elasticsearch (sharded por orderId)
  Proyeccion: 4 instancias consumer (Kafka consumer group)
  Event bus: Kafka con 12 particiones

Metricas:
  | Metrica | Target |
  |---------|--------|
  | Latencia escritura p95 | < 50ms |
  | Latencia lectura p95 | < 20ms |
  | Lag de proyeccion | < 500ms |
  | Disponibilidad lectura | 99.95% |
  | Disponibilidad escritura | 99.99% |

Como reconstruyo un modelo de lectura desde cero?

Si el modelo de lectura esta corrupto o necesita cambio de esquema, replay los eventos desde el event store. Deten los consumers de proyeccion, trunca el modelo de lectura, y replay todos los eventos desde el inicio. Para event stores grandes, usa replay basado en snapshots: procesa eventos en lotes de 10,000 con checkpoints. Alternativamente, ejecuta una “proyeccion sombra” junto a la existente, verifica consistencia de datos, luego cambia los query handlers al nuevo modelo de lectura.