Last updated on

CQRS con ejemplos en TypeScript


En este artículo vamos a explorar el patrón CQRS (Command Query Responsibility Segregation).
Su objetivo es separar las operaciones de lectura (Queries) de las operaciones de escritura (Commands) para obtener sistemas más mantenibles, escalables y fáciles de optimizar.


¿Qué es CQRS?

El patrón CQRS nos dice que:

  • Las Queries solo leen datos (no modifican el estado del sistema).
  • Los Commands solo modifican datos (pero no devuelven resultados complejos, a lo sumo un identificador o confirmación).

Esto evita mezclar responsabilidades en una misma capa y facilita escalar cada parte de forma independiente.


Ventajas de utilizar CQRS

  • Separación clara de responsabilidades: Lectura y escritura evolucionan sin afectarse mutuamente.
  • Escalabilidad independiente: Podés escalar el lado de lecturas (normalmente más demandado) sin necesidad de escalar escrituras.
  • Optimización por caso de uso: La base de datos de lectura puede estar optimizada para consultas rápidas (ej. MongoDB, Redis), mientras que la de escritura puede garantizar consistencia fuerte (ej. PostgreSQL).
  • Facilita Event Sourcing: CQRS se integra muy bien con patrones de event sourcing, donde cada cambio en el sistema se guarda como un evento.
  • Mantenibilidad: El código se vuelve más fácil de entender y probar.

Desventajas de CQRS

  • Mayor complejidad inicial: No siempre se justifica para sistemas pequeños.
  • Consistencia eventual: En escenarios distribuidos, puede que las lecturas no reflejen inmediatamente las escrituras.
  • Curva de aprendizaje: Introduce más capas y patrones (commands, queries, handlers, buses de mensajes).

Casos de uso ideales

  • Sistemas con altísima carga de lectura (ej. aplicaciones de e-commerce con millones de productos).
  • Aplicaciones que requieren auditoría y trazabilidad (ej. banca, fintech, seguros).
  • Arquitecturas de microservicios donde cada módulo puede manejar lecturas y escrituras por separado.
  • Escenarios donde la performance de consultas es crítica y necesitamos optimizar la lectura sin comprometer la escritura.

Ejemplo básico en TypeScript

Imaginemos un microservicio de usuarios.

1. Definimos nuestros modelos

// domain/User.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

2. Creamos los Commands

Los commands representan acciones que cambian el estado:

// commands/CreateUserCommand.ts
export class CreateUserCommand {
  constructor(
    public readonly name: string,
    public readonly email: string
  ) {}
}

El handler de este comando sería el encargado de ejecutarlo:

// commands/handlers/CreateUserHandler.ts
import { CreateUserCommand } from "../CreateUserCommand";
import { User } from "../../domain/User";

export class CreateUserHandler {
  private users: User[] = [];

  execute(command: CreateUserCommand): User {
    const newUser: User = {
      id: crypto.randomUUID(),
      name: command.name,
      email: command.email,
    };

    this.users.push(newUser);
    return newUser;
  }
}

3. Creamos las Queries

Las queries representan consultas que solo leen datos:

// queries/GetUserByEmailQuery.ts
export class GetUserByEmailQuery {
  constructor(public readonly email: string) {}
}

Handler de la query:

// queries/handlers/GetUserByEmailHandler.ts
import { GetUserByEmailQuery } from "../GetUserByEmailQuery";
import { User } from "../../domain/User";

export class GetUserByEmailHandler {
  constructor(private readonly users: User[]) {}

  execute(query: GetUserByEmailQuery): User | undefined {
    return this.users.find(u => u.email === query.email);
  }
}

Ejemplo de uso combinado

import { CreateUserCommand } from "./commands/CreateUserCommand";
import { CreateUserHandler } from "./commands/handlers/CreateUserHandler";
import { GetUserByEmailQuery } from "./queries/GetUserByEmailQuery";
import { GetUserByEmailHandler } from "./queries/handlers/GetUserByEmailHandler";

// Inicializamos el handler de escritura
const createUserHandler = new CreateUserHandler();

// Creamos un usuario
const command = new CreateUserCommand("Max Rossi", "max@example.com");
const newUser = createUserHandler.execute(command);

console.log("Usuario creado:", newUser);

// Ahora inicializamos el handler de lectura con la misma fuente de datos
const getUserHandler = new GetUserByEmailHandler([newUser]);

// Ejecutamos una query
const query = new GetUserByEmailQuery("max@example.com");
const userFound = getUserHandler.execute(query);

console.log("Usuario encontrado:", userFound);

Conclusión

Con CQRS separamos las responsabilidades de lectura y escritura.
Esto nos ayuda a construir sistemas más limpios, fáciles de probar y que escalan de manera independiente.

Este ejemplo fue sencillo, pero CQRS brilla en arquitecturas de microservicios o sistemas distribuidos donde la complejidad crece rápidamente.


👉 En próximos artículos podríamos ver cómo combinar CQRS + Event Sourcing, y cómo usar buses de mensajes (ej. Kafka o RabbitMQ) para manejar la comunicación entre comandos y queries.