← Volver al listado de tecnologías

Separación de Responsabilidades

Por: SiempreListo
cqrsarquitecturasolidtypescriptgopython

Separacion de Responsabilidades

En este capitulo profundizaremos en como separar las operaciones de lectura y escritura, entendiendo la diferencia entre CQS y CQRS, y viendo implementaciones practicas de Write Models y Read Models.

El Principio Fundamental

El principio fundamental de CQRS es que los metodos que modifican estado no deben retornar datos, y los metodos que retornan datos no deben modificar estado. Esta regla simple tiene profundas implicaciones en como disenamos nuestros sistemas.

CQS vs CQRS: Entendiendo la Diferencia

Antes de continuar, es importante distinguir entre dos conceptos relacionados pero diferentes.

CQS (Command Query Separation) es un principio que aplica a nivel de metodo dentro de una clase. Fue propuesto por Bertrand Meyer y establece que cada metodo debe ser o un comando que modifica estado, o una consulta que retorna datos, pero nunca ambos:

// TypeScript - CQS a nivel de método
class OrderService {
  // Command: modifica estado, no retorna datos
  async placeOrder(order: Order): Promise<void> {
    await this.repository.save(order);
  }

  // Query: retorna datos, no modifica estado
  async getOrder(id: string): Promise<Order | null> {
    return this.repository.findById(id);
  }
}

CQRS eleva este principio a nivel arquitectonico. No solo separa metodos, sino que usa modelos de datos completamente diferentes para lecturas y escrituras. Esto significa clases diferentes, repositorios diferentes, e incluso bases de datos diferentes.

Write Model: Guardián de las Reglas de Negocio

El Write Model es el modelo que usamos para todas las operaciones que modifican datos. Su responsabilidad principal es mantener la integridad del dominio, es decir, asegurar que todas las reglas de negocio se cumplan antes y despues de cada cambio.

En CQRS, el Write Model suele implementarse como un Agregado (Aggregate). Un agregado es un grupo de objetos de dominio que se tratan como una unidad para propositos de cambios de datos. Tiene una raiz de agregado (Aggregate Root) que es el unico punto de entrada para modificaciones:

// TypeScript - Write Model
class OrderAggregate {
  private id: string;
  private items: OrderItem[] = [];
  private status: OrderStatus = "pending";

  addItem(productId: string, quantity: number, price: number): void {
    if (this.status !== "pending") {
      throw new Error("Cannot modify confirmed order");
    }
    this.items.push({ productId, quantity, price });
  }

  confirm(): void {
    if (this.items.length === 0) {
      throw new Error("Cannot confirm empty order");
    }
    this.status = "confirmed";
  }
}
// Go - Write Model
type OrderAggregate struct {
    id     string
    items  []OrderItem
    status OrderStatus
}

func (o *OrderAggregate) AddItem(productID string, qty int, price float64) error {
    if o.status != StatusPending {
        return errors.New("cannot modify confirmed order")
    }
    o.items = append(o.items, OrderItem{productID, qty, price})
    return nil
}

func (o *OrderAggregate) Confirm() error {
    if len(o.items) == 0 {
        return errors.New("cannot confirm empty order")
    }
    o.status = StatusConfirmed
    return nil
}
# Python - Write Model
class OrderAggregate:
    def __init__(self, id: str):
        self._id = id
        self._items: list[OrderItem] = []
        self._status = OrderStatus.PENDING

    def add_item(self, product_id: str, quantity: int, price: Decimal) -> None:
        if self._status != OrderStatus.PENDING:
            raise DomainError("Cannot modify confirmed order")
        self._items.append(OrderItem(product_id, quantity, price))

    def confirm(self) -> None:
        if not self._items:
            raise DomainError("Cannot confirm empty order")
        self._status = OrderStatus.CONFIRMED

Read Model: Optimizado para Consultas

El Read Model es el modelo que usamos exclusivamente para consultas. A diferencia del Write Model, no contiene logica de negocio ni validaciones. Su unico objetivo es responder consultas de la forma mas rapida posible.

La clave del Read Model es la desnormalizacion. Mientras que en una base de datos normalizada evitamos duplicar datos, en el Read Model deliberadamente duplicamos informacion para evitar JOINs costosos. Tambien precalculamos valores que de otro modo tendriamos que calcular en cada consulta:

// TypeScript - Read Model (desnormalizado)
interface OrderReadModel {
  id: string;
  customerName: string;  // Desnormalizado
  customerEmail: string; // Desnormalizado
  items: {
    productName: string; // Desnormalizado
    quantity: number;
    unitPrice: number;
    subtotal: number;    // Precalculado
  }[];
  total: number;         // Precalculado
  status: string;
  createdAt: string;
}
// Go - Read Model
type OrderReadModel struct {
    ID            string           `json:"id"`
    CustomerName  string           `json:"customerName"`
    CustomerEmail string           `json:"customerEmail"`
    Items         []OrderItemView  `json:"items"`
    Total         float64          `json:"total"`
    Status        string           `json:"status"`
    CreatedAt     string           `json:"createdAt"`
}

type OrderItemView struct {
    ProductName string  `json:"productName"`
    Quantity    int     `json:"quantity"`
    UnitPrice   float64 `json:"unitPrice"`
    Subtotal    float64 `json:"subtotal"`
}
# Python - Read Model
@dataclass(frozen=True)
class OrderReadModel:
    id: str
    customer_name: str
    customer_email: str
    items: list["OrderItemView"]
    total: Decimal
    status: str
    created_at: str

@dataclass(frozen=True)
class OrderItemView:
    product_name: str
    quantity: int
    unit_price: Decimal
    subtotal: Decimal

Repositorios Separados: Una Interfaz para Cada Proposito

En CQRS, tenemos repositorios diferentes para escritura y lectura. El repositorio de escritura trabaja con agregados y se enfoca en persistir cambios. El repositorio de lectura trabaja con Read Models y se enfoca en consultas eficientes:

// TypeScript - Repositorios
interface OrderWriteRepository {
  save(order: OrderAggregate): Promise<void>;
  getById(id: string): Promise<OrderAggregate | null>;
}

interface OrderReadRepository {
  findById(id: string): Promise<OrderReadModel | null>;
  findByCustomer(customerId: string): Promise<OrderReadModel[]>;
  search(criteria: SearchCriteria): Promise<PaginatedResult<OrderReadModel>>;
}
// Go - Repositorios
type OrderWriteRepository interface {
    Save(ctx context.Context, order *OrderAggregate) error
    GetByID(ctx context.Context, id string) (*OrderAggregate, error)
}

type OrderReadRepository interface {
    FindByID(ctx context.Context, id string) (*OrderReadModel, error)
    FindByCustomer(ctx context.Context, customerID string) ([]OrderReadModel, error)
    Search(ctx context.Context, criteria SearchCriteria) (*PaginatedResult, error)
}
# Python - Repositorios
from abc import ABC, abstractmethod

class OrderWriteRepository(ABC):
    @abstractmethod
    async def save(self, order: OrderAggregate) -> None: ...

    @abstractmethod
    async def get_by_id(self, id: str) -> OrderAggregate | None: ...

class OrderReadRepository(ABC):
    @abstractmethod
    async def find_by_id(self, id: str) -> OrderReadModel | None: ...

    @abstractmethod
    async def find_by_customer(self, customer_id: str) -> list[OrderReadModel]: ...

Flujo de Datos: Como se Conecta Todo

Veamos el flujo completo de datos en un sistema CQRS:

1. Cliente envia comando → CreateOrderCommand
2. Handler procesa comando → OrderAggregate.create()
3. Agregado persiste → WriteRepository.save()
4. Evento publicado → OrderCreatedEvent
5. Proyeccion actualiza → ReadModel sincronizado
6. Cliente consulta → Query retorna ReadModel optimizado

Paso a paso:

  1. El cliente envia un Command (ej: CreateOrderCommand) a traves de la API
  2. El Command Handler recibe el comando y crea/modifica el agregado correspondiente
  3. El agregado se persiste en la base de datos del Write Model usando el repositorio de escritura
  4. El agregado genera eventos de dominio que describen lo que ocurrio
  5. Una Proyeccion escucha estos eventos y actualiza el Read Model en consecuencia
  6. Cuando el cliente hace una Query, esta se ejecuta contra el Read Model optimizado

Este flujo introduce un concepto importante: la consistencia eventual. Como el Read Model se actualiza de forma asincrona, existe un breve periodo donde el Write Model y el Read Model estan desincronizados. Exploraremos esto en detalle en el capitulo 5.

Proximos Pasos

En el siguiente capitulo disenaremos los Commands que encapsulan las intenciones de modificacion.


Glosario

CQS (Command Query Separation)

Definicion: Principio de diseno que establece que cada metodo de una clase debe ser o un comando que realiza una accion, o una consulta que retorna datos, pero nunca ambos.

Por que es importante: Es el fundamento conceptual de CQRS. Aplicarlo a nivel de metodo mejora la claridad del codigo y facilita el razonamiento sobre efectos secundarios.

Ejemplo practico: El metodo placeOrder() es un comando que guarda el pedido pero no retorna nada (void). El metodo getOrder() es una query que retorna el pedido pero no modifica nada.


Agregado (Aggregate)

Definicion: Grupo de objetos de dominio que se tratan como una unidad para propositos de cambios de datos. Tiene una raiz (Aggregate Root) que es el unico punto de entrada para modificaciones.

Por que es importante: Define los limites de consistencia transaccional. Todo lo que esta dentro del agregado es consistente despues de cada operacion. Simplifica la logica al tener un unico punto de entrada.

Ejemplo practico: Un OrderAggregate contiene el pedido y sus items. Para agregar un item, debes hacerlo a traves del agregado (order.addItem()), no directamente sobre la lista de items. Esto permite que el agregado valide reglas como “no se pueden agregar items a un pedido confirmado”.


Raiz de Agregado (Aggregate Root)

Definicion: La entidad principal de un agregado que sirve como punto de entrada unico para todas las operaciones. Es la unica entidad del agregado que puede ser referenciada desde fuera.

Por que es importante: Garantiza que todas las reglas de negocio del agregado se cumplan, ya que cualquier modificacion debe pasar por ella.

Ejemplo practico: OrderAggregate es la raiz de agregado. Para modificar un OrderItem, debes hacerlo a traves de metodos del OrderAggregate, nunca directamente.


Desnormalizacion

Definicion: Tecnica de diseno de base de datos donde se duplican datos intencionalmente para optimizar consultas, sacrificando espacio de almacenamiento y complejidad de actualizacion a cambio de velocidad de lectura.

Por que es importante: En CQRS, el Read Model puede estar altamente desnormalizado porque no necesita soportar escrituras complejas. Esto permite consultas extremadamente rapidas.

Ejemplo practico: En el Read Model de un pedido, guardamos customerName y customerEmail directamente, aunque estos datos ya existen en la tabla de clientes. Esto evita un JOIN cada vez que mostramos el pedido.


Proyeccion

Definicion: Componente que escucha eventos de dominio y actualiza el Read Model en consecuencia. Transforma los datos del formato de escritura al formato de lectura.

Por que es importante: Es el puente que mantiene sincronizados el Write Model y el Read Model. Permite que cada modelo tenga la estructura optima para su proposito.

Ejemplo practico: Cuando se emite OrderCreatedEvent, la proyeccion OrderProjection crea un nuevo registro en el Read Model con el nombre del cliente ya incluido (desnormalizado).