Saltar al contenido

El patrón CQRS: separar lecturas de escrituras

CQRS usa modelos distintos para leer y escribir datos: cuándo resuelve problemas reales y cuándo es solo complejidad innecesaria.

4 min de lectura
Arquitectura CQRS mostrando rutas separadas para comandos y consultas

La mayoría de las aplicaciones usan el mismo modelo para leer y escribir datos. Una entidad User se mapea a una tabla users, y el mismo modelo sirve tanto para la respuesta de la API como para la lógica de actualización. Esto funciona hasta que tus patrones de lectura divergen significativamente de tus patrones de escritura — cuando la forma de los datos que tu UI necesita es completamente distinta de cómo los almacena tu modelo de dominio. CQRS aborda esto usando modelos separados, optimizados para cada caso.

La idea central

Los comandos modifican el estado. Las consultas leen el estado. En CQRS usan modelos distintos, potencialmente respaldados por almacenes de datos diferentes.

tstypescript
// ❌ Single model for reads and writes
class OrderService {
  async getOrders(userId: string): Promise<Order[]> {
    // Query returns domain objects with all business logic
    // UI only needs id, total, status — gets entire aggregate
    return this.orderRepo.findByUser(userId);
  }
 
  async createOrder(data: CreateOrderDto): Promise<Order> {
    const order = new Order(data);
    order.validate();
    return this.orderRepo.save(order);
  }
}
 
// ✅ Separate read and write models
// Write side: rich domain model with business rules
class OrderCommandHandler {
  async handle(command: CreateOrderCommand): Promise<string> {
    const order = new Order(command.userId);
 
    for (const item of command.items) {
      order.addItem(item.productId, item.quantity, item.price);
    }
 
    order.validate();
    await this.orderRepo.save(order);
    return order.id;
  }
}
 
// Read side: flat, optimized projections for queries
class OrderQueryHandler {
  async getOrderSummaries(userId: string): Promise<OrderSummary[]> {
    // Direct database query returning exactly what the UI needs
    return this.db.query(`
      SELECT o.id, o.status, o.total_amount, o.created_at,
             COUNT(oi.id) as item_count
      FROM orders o
      LEFT JOIN order_items oi ON oi.order_id = o.id
      WHERE o.user_id = $1
      GROUP BY o.id
      ORDER BY o.created_at DESC
    `, [userId]);
  }
}

Cuándo ayuda CQRS

CQRS añade complejidad arquitectónica. Está justificado cuando tus patrones de lectura y escritura son fundamentalmente distintos.

tstypescript
// Scenario: Dashboard that aggregates data from multiple entities
// Without CQRS, the dashboard query joins 5 tables and computes aggregates on every request
 
// ❌ Complex query on every read
async function getDashboard(userId: string) {
  const orders = await orderRepo.findByUser(userId);
  const payments = await paymentRepo.findByUser(userId);
  const returns = await returnRepo.findByUser(userId);
 
  // Expensive aggregation on every request
  return {
    totalOrders: orders.length,
    totalSpent: orders.reduce((sum, o) => sum + o.total, 0),
    pendingPayments: payments.filter(p => p.status === "pending").length,
    returnRate: returns.length / orders.length,
    recentActivity: mergeAndSort(orders, payments, returns).slice(0, 10),
  };
}
 
// ✅ Pre-computed read model updated when writes happen
interface UserDashboardReadModel {
  userId: string;
  totalOrders: number;
  totalSpent: number;
  pendingPayments: number;
  returnRate: number;
  recentActivity: ActivityEntry[];
  lastUpdated: string;
}
 
// Read model is updated asynchronously when events occur
async function handleOrderCreated(event: OrderCreatedEvent) {
  await db.query(`
    UPDATE user_dashboard
    SET total_orders = total_orders + 1,
        total_spent = total_spent + $2,
        last_updated = NOW()
    WHERE user_id = $1
  `, [event.data.userId, event.data.totalAmount]);
}
 
// Dashboard query is now a single table lookup
async function getDashboard(userId: string): Promise<UserDashboardReadModel> {
  const result = await db.query(
    "SELECT * FROM user_dashboard WHERE user_id = $1",
    [userId]
  );
  return result.rows[0];
}

Proyecciones: construir modelos de lectura

Las proyecciones transforman los eventos del lado de escritura en estructuras de datos optimizadas para lectura.

tstypescript
class OrderSummaryProjection {
  async handle(event: DomainEvent): Promise<void> {
    switch (event.type) {
      case "order.created":
        await this.onOrderCreated(event as OrderCreatedEvent);
        break;
      case "order.item_added":
        await this.onItemAdded(event as ItemAddedEvent);
        break;
      case "order.submitted":
        await this.onOrderSubmitted(event as OrderSubmittedEvent);
        break;
      case "order.cancelled":
        await this.onOrderCancelled(event as OrderCancelledEvent);
        break;
    }
  }
 
  private async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
    await this.db.query(`
      INSERT INTO order_summaries (id, user_id, status, item_count, total_amount, created_at)
      VALUES ($1, $2, 'draft', 0, 0, $3)
    `, [event.aggregateId, event.data.userId, event.occurredAt]);
  }
 
  private async onItemAdded(event: ItemAddedEvent): Promise<void> {
    await this.db.query(`
      UPDATE order_summaries
      SET item_count = item_count + 1,
          total_amount = total_amount + $2
      WHERE id = $1
    `, [event.aggregateId, event.data.price * event.data.quantity]);
  }
 
  private async onOrderSubmitted(event: OrderSubmittedEvent): Promise<void> {
    await this.db.query(`
      UPDATE order_summaries SET status = 'submitted' WHERE id = $1
    `, [event.aggregateId]);
  }
 
  private async onOrderCancelled(event: OrderCancelledEvent): Promise<void> {
    await this.db.query(`
      UPDATE order_summaries SET status = 'cancelled' WHERE id = $1
    `, [event.aggregateId]);
  }
}

Consistencia eventual entre modelos

El modelo de lectura se actualiza de forma asíncrona después de las escrituras. Esto significa que las consultas pueden devolver datos ligeramente desactualizados.

tstypescript
// ❌ Ignoring the consistency gap
app.post("/orders", async (req, res) => {
  await commandBus.dispatch(new CreateOrderCommand(req.body));
  // Immediately redirecting to a page that reads the new order
  // But the read model might not have the new order yet!
  res.redirect(`/orders`);
});
 
// ✅ Handling the consistency gap explicitly
app.post("/orders", async (req, res) => {
  const orderId = await commandBus.dispatch(new CreateOrderCommand(req.body));
 
  // Option 1: Return the command result directly (not from read model)
  res.status(202).json({ orderId, status: "processing" });
});
 
// Client-side: poll or use websocket for read model updates
async function waitForReadModel(orderId: string, maxAttempts = 10): Promise<OrderSummary> {
  for (let i = 0; i < maxAttempts; i++) {
    const summary = await fetch(`/api/orders/${orderId}/summary`);
    if (summary.ok) return summary.json();
    await new Promise(resolve => setTimeout(resolve, 200 * Math.pow(2, i)));
  }
  throw new Error("Read model not yet updated");
}

Reconstruir modelos de lectura

Una ventaja de CQRS: los modelos de lectura se pueden reconstruir a partir del historial de eventos. Si añades una nueva vista de dashboard, reconstruye la proyección desde los eventos históricos.

tstypescript
async function rebuildProjection(
  projection: OrderSummaryProjection,
  eventStore: EventStore
): Promise<void> {
  // Clear existing read model
  await projection.reset();
 
  // Replay all events in order
  let lastPosition = 0;
  const batchSize = 1000;
 
  while (true) {
    const events = await eventStore.getEvents({
      afterPosition: lastPosition,
      limit: batchSize,
    });
 
    if (events.length === 0) break;
 
    for (const event of events) {
      await projection.handle(event);
      lastPosition = event.position;
    }
 
    console.log(`Rebuilt up to position ${lastPosition}`);
  }
 
  console.log("Projection rebuild complete");
}

Conclusiones clave

  1. CQRS separa los modelos de lectura y escritura — cada lado está optimizado para sus patrones de acceso específicos
  2. Los modelos de lectura precomputados eliminan joins costosos — las consultas del dashboard se convierten en simples búsquedas
  3. Las proyecciones transforman eventos en estructuras optimizadas para lectura — son el puente entre el lado de escritura y el de lectura
  4. Acepta la consistencia eventual — los modelos de lectura se actualizan de forma asíncrona, diseña tu UI en consecuencia
  5. Los modelos de lectura se pueden reconstruir — reproduce los eventos para crear nuevas vistas o corregir errores en las proyecciones
  6. CQRS no siempre es necesario — si tus lecturas y escrituras usan la misma forma, un único modelo es más simple
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX