Los cuatro artículos anteriores de la serie pusieron las bases para que un sistema distribuido se mantenga coherente: Saga para orquestar workflows, Outbox para publicar eventos fiables, Inbox para consumirlos sin duplicados, Idempotency Keys para proteger la API. Queda una pregunta: ¿qué se hace con los eventos una vez publicados?

La respuesta más frecuente es: se usan para construir vistas de lectura. Es exactamente lo que propone el patrón CQRS (Command Query Responsibility Segregation): separar el modelo de escritura del modelo de lectura, cuando ambos divergen lo suficiente como para que forzarlos en una misma estructura cueste más que duplicarlos.

Este artículo cierra la serie manteniendo la misma línea: código concreto, sin dogma, y sobre todo sin Event Sourcing. El CQRS pragmático en Django se resume en “dos modelos, dos caminos, un puente de eventos”, y basta en la gran mayoría de los casos.

El problema: reads y writes no quieren lo mismo

Un pedido e-commerce, del lado escritura, es un objeto de negocio rico con invariantes: un estado que no puede retroceder, líneas que deben corresponder a stock disponible, un importe calculado a partir de reglas. El modelo Django refleja esas restricciones.

class Pedido(models.Model):
    estado = models.CharField(choices=ESTADOS_VALIDOS)
    cliente = models.ForeignKey(Cliente, on_delete=PROTECT)
    direccion_envio = models.ForeignKey(Direccion, on_delete=PROTECT)

    def confirmar(self):
        if self.estado != "carrito":
            raise InvalidTransition(...)
        ...

Del lado lectura, el mismo dato toma otra forma. La pantalla “Mis pedidos” del cliente quiere mostrar: número, fecha, total, estado legible (“En preparación”), número de artículos, imagen del primer producto. Para producir esa pantalla, el servidor une Pedido, LineaPedido, Producto, ImagenProducto, EstadoLabel, sobre decenas de miles de filas, cada vez que se abre el dashboard.

Cuando la aplicación crece, emergen dos presiones opuestas. El write model quiere mantenerse normalizado, restringido, íntegro. El read model quiere ser plano, rápido, a veces adelantado a la base al precio de una coherencia eventual. Intentar servir ambas necesidades desde las mismas tablas acaba sacrificando una de las dos.

El principio: dos modelos, dos caminos

CQRS dice: dejad de buscar el compromiso, duplicad. Las commands modifican el estado en el modelo de escritura. Las queries leen en un modelo de lectura, estructuralmente diferente, actualizado en segundo plano a partir de los eventos emitidos por las commands.

Cliente → API command → Write model (Django ORM) → evento (Outbox)
                                                       ↓
                                                    Broker (Kafka)
                                                       ↓
Cliente → API query  ← Read model (tabla plana) ← Inbox + denormalizer

Los dos caminos ya no tienen nada que ver. El write model permanece un dominio DDD limpio con sus invariantes. El read model es una proyección optimizada para las pantallas reales de la aplicación. Sin joins complejos en el camino de lectura, sin restricciones del modelo de lectura que suban a contaminar la lógica de negocio.

El read model: una tabla plana alimentada por eventos

Un ejemplo concreto para la pantalla “Mis pedidos”:

class PedidoReadModel(models.Model):
    pedido_id = models.UUIDField(primary_key=True)
    cliente_id = models.BigIntegerField(db_index=True)
    numero = models.CharField(max_length=32)
    creado_el = models.DateTimeField()
    estado = models.CharField(max_length=32)
    estado_label = models.CharField(max_length=64)
    total_centimos = models.IntegerField()
    numero_articulos = models.IntegerField()
    imagen_apercu_url = models.URLField(blank=True)
    ultima_actualizacion = models.DateTimeField()

    class Meta:
        indexes = [
            models.Index(fields=["cliente_id", "-creado_el"]),
        ]

Sin ForeignKey. Sin relación. Sin lógica. Es deliberadamente una “vista desnormalizada plana” que sirve un único uso: mostrar la lista de pedidos de un cliente, ordenada por fecha. La consulta de lectura se vuelve trivial:

def lista_pedidos(cliente_id):
    return PedidoReadModel.objects.filter(cliente_id=cliente_id)[:50]

Un único SELECT indexado, sin JOIN, sin N+1. Rendimiento constante sin importar el número de líneas o productos por pedido.

El puente: un consumer que desnormaliza

El read model se construye y mantiene por un consumer que escucha los eventos emitidos por el write model vía el Outbox. Cada evento dispara una actualización del read model, protegida por el patrón Inbox para evitar duplicados.

def handle_pedido_confirmado(event: dict) -> None:
    try:
        with transaction.atomic():
            InboxEvent.objects.create(
                event_id=event["event_id"],
                consumer="pedidos_read_model",
            )
            PedidoReadModel.objects.update_or_create(
                pedido_id=event["pedido_id"],
                defaults={
                    "cliente_id": event["cliente_id"],
                    "numero": event["numero"],
                    "creado_el": event["creado_el"],
                    "estado": "confirmado",
                    "estado_label": "Confirmado",
                    "total_centimos": event["total_centimos"],
                    "numero_articulos": event["numero_articulos"],
                    "imagen_apercu_url": event["imagen_apercu_url"],
                    "ultima_actualizacion": timezone.now(),
                },
            )
    except IntegrityError:
        return

El update_or_create es intencional: el mismo evento puede referirse a un pedido que ya fue insertado en el read model por un evento previo (por ejemplo PedidoCreado y luego PedidoConfirmado). Queremos el estado más reciente, no un fallo en la clave primaria.

El payload del evento debe contener todo lo que el read model necesita. Es una elección estructurante: evitamos que el consumer vuelva a consultar el write model, porque eso reacopla ambos modelos y pierde el sentido de la separación.

Tres formas de materializar el read model

El read model no es necesariamente una tabla Django clásica. Tres opciones coexisten según el volumen y la frescura exigida.

Tabla desnormalizada gestionada por el consumer. El enfoque por defecto, como arriba. Simple, transaccional, legible con el ORM de Django. Es la buena elección para la gran mayoría de los casos.

Vista materializada PostgreSQL. En lugar de mantener la tabla a mano vía eventos, declaramos una MATERIALIZED VIEW que agrega los datos del write model. Un REFRESH MATERIALIZED VIEW CONCURRENTLY periódico la actualiza. Sin necesidad de Outbox ni consumer. La contrapartida: la frescura está limitada por la frecuencia del refresh, y el coste del refresh crece con el volumen.

Índice Elasticsearch u OpenSearch. Cuando las consultas se convierten en full-text search o agregaciones analíticas, un read model en un motor dedicado se vuelve pertinente. El consumer escribe en Elasticsearch en lugar de una tabla SQL. El patrón es idéntico, solo cambia el destino.

La elección depende de la consulta a servir, no de un principio abstracto. En un mismo proyecto, varios read models pueden coexistir, cada uno optimizado para su uso.

La coherencia eventual, y lo que implica

El read model está retrasado respecto al write model. Entre el momento en que un pedido se confirma y el momento en que aparece en el read model, transcurren unos milisegundos a unos segundos, según el throughput del broker y la carga de los consumers. Es la coherencia eventual, y es el compromiso fundamental de CQRS.

Tres consecuencias prácticas.

Tras un pedido, el cliente debe ver su pedido. Si la página de confirmación lee el read model, puede mostrar “Sin pedidos” durante los 200 ms en que el evento aún no se ha propagado. La solución: leer el write model justo después de una command, y cambiar al read model para las páginas genéricas. El compromiso se gestiona página por página, no globalmente.

Los informes administrativos pueden vivir con unos segundos de retraso. Es el caso ideal del read model: un dashboard “ventas del día” que se refresca cada 30 segundos no sufre por tener 2 segundos de desfase respecto al write model.

El read model se puede reconstruir desde cero. Si la proyección tiene un bug o queremos añadir un campo, vaciamos la tabla y reproducimos todos los eventos desde el principio. Ese mecanismo exige una retención larga del lado broker, o una segunda fuente para reproducir. Cuando es posible, transforma el read model en un caché reconstruible en lugar de una fuente de verdad frágil.

CQRS no es Event Sourcing

La confusión más frecuente. CQRS dice “dos modelos, dos caminos”. Event Sourcing dice “deja de almacenar el estado, almacena la secuencia de eventos que produjeron ese estado”. Ambos pueden combinarse, pero no es obligatorio.

El CQRS pragmático presentado aquí mantiene un write model Django clásico con una tabla Pedido que contiene el estado actual. Los eventos emitidos hacia el read model describen lo que cambió, pero no son la fuente de verdad. La fuente de verdad sigue siendo el ORM, como en cualquier proyecto Django.

Event Sourcing implica almacenar los propios eventos como estado primario, y reconstruir cualquier información mediante replay. Es un enfoque radicalmente diferente, con sus propios desafíos (versionado de eventos, snapshots, rendimiento de replay). Merece su propio artículo, incluso su propia serie.

Cuándo no hacer CQRS

El patrón añade infraestructura: Outbox, broker, consumer, tabla desnormalizada, gestión de la coherencia eventual. Para una aplicación que sirve a unos miles de usuarios con consultas simples, es sobreingeniería.

CQRS se vuelve pertinente en cuanto:

  • las consultas de lectura cuestan significativamente más que las escrituras (joins pesados, agregaciones)
  • los modelos de escritura y lectura divergen al punto que los Serializer de Django REST Framework se vuelven ilegibles
  • se quiere escalar horizontalmente las lecturas sin tocar el write model
  • ya se ha adoptado el Outbox por otras razones, y el read model se convierte en un consumo gratuito de los eventos existentes

A la inversa, para un CRUD clásico con pantallas que mapean casi 1-1 a los modelos, el ORM de Django con algunos select_related y prefetch_related sigue siendo muy superior en simplicidad y coherencia.

Conclusión

CQRS pragmático es un patrón de optimización, no un dogma arquitectónico. Se introduce donde la divergencia read/write duele, no por principio. El beneficio es poder dar a cada lado lo que quiere: un write model íntegro y restringido, un read model rápido y plano.

La serie sobre patrones de arquitectura distribuida termina aquí, con un sistema donde cada frontera tiene su protección y cada camino de lectura está optimizado para su uso. Saga, Outbox, Inbox, Idempotency Keys y CQRS forman juntos una caja de herramientas que se ensambla según las necesidades reales, no según una visión idealizada de arquitectura.