Separación de Responsabilidades
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:
- El cliente envia un Command (ej: CreateOrderCommand) a traves de la API
- El Command Handler recibe el comando y crea/modifica el agregado correspondiente
- El agregado se persiste en la base de datos del Write Model usando el repositorio de escritura
- El agregado genera eventos de dominio que describen lo que ocurrio
- Una Proyeccion escucha estos eventos y actualiza el Read Model en consecuencia
- 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).