Diseño de Queries y Read Models
Diseno de Queries y Read Models
En este capitulo aprenderemos a disenar Queries efectivas y Read Models optimizados, entendiendo como estructurar las consultas y que patrones de desnormalizacion aplicar.
Que es una Query
Una Query (consulta) es un objeto que representa una solicitud de informacion al sistema. A diferencia de un Command que pide un cambio, una Query solo pide datos. La caracteristica fundamental de una Query es que nunca modifica el estado del sistema: puedes ejecutarla mil veces y el sistema permanece exactamente igual.
Que es un Read Model
Un Read Model (modelo de lectura) es una estructura de datos optimizada para responder un tipo especifico de consulta. A diferencia del Write Model que esta normalizado y contiene logica de negocio, el Read Model esta desnormalizado y es puramente datos: no tiene metodos de negocio, solo campos.
Caracteristicas de una Query Bien Disenada
-
Declarativa: Describe que datos necesita, no como obtenerlos. El Query Handler decide la implementacion.
-
Sin efectos secundarios: No modifica estado del sistema. Ejecutarla multiples veces produce el mismo resultado.
-
Retorna datos: A diferencia de un Command, una Query siempre devuelve informacion (aunque sea “no encontrado”).
-
Especifica: Cada Query esta disenada para un caso de uso concreto. No hay queries “genericas” que sirvan para todo.
Estructura Base de una Query
Las Queries tienen una estructura mas simple que los Commands. El elemento clave es el tipo de resultado que retornan, expresado como un parametro generico TResult:
// TypeScript
interface Query<TResult> {
readonly queryId: string;
}
interface QueryHandler<TQuery extends Query<TResult>, TResult> {
execute(query: TQuery): Promise<TResult>;
}
// Go
type Query interface {
QueryID() string
}
type QueryHandler[Q Query, R any] interface {
Execute(ctx context.Context, query Q) (R, error)
}
# Python
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Generic, TypeVar
from uuid import uuid4
R = TypeVar("R")
@dataclass(frozen=True)
class Query(ABC, Generic[R]):
query_id: str = field(default_factory=lambda: str(uuid4()))
class QueryHandler(ABC, Generic[R]):
@abstractmethod
async def execute(self, query: Query[R]) -> R: ...
Queries de OrderFlow: Ejemplos Practicos
Veamos queries concretas para nuestro sistema de pedidos. Cada una representa un caso de uso de visualizacion diferente.
GetOrderById: Obtener Detalle de un Pedido
Esta query obtiene toda la informacion de un pedido especifico. El Read Model incluye datos desnormalizados como el nombre del cliente:
// TypeScript
class GetOrderByIdQuery implements Query<OrderDetailView | null> {
readonly queryId = crypto.randomUUID();
constructor(readonly orderId: string) {}
}
interface OrderDetailView {
id: string;
customerName: string;
items: OrderItemView[];
total: number;
status: string;
createdAt: string;
}
// Go
type GetOrderByIDQuery struct {
queryID string
OrderID string
}
func NewGetOrderByIDQuery(orderID string) GetOrderByIDQuery {
return GetOrderByIDQuery{
queryID: uuid.NewString(),
OrderID: orderID,
}
}
type OrderDetailView struct {
ID string `json:"id"`
CustomerName string `json:"customerName"`
Items []OrderItemView `json:"items"`
Total float64 `json:"total"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
}
# Python
@dataclass(frozen=True)
class GetOrderByIdQuery(Query["OrderDetailView | None"]):
order_id: str
@dataclass(frozen=True)
class OrderDetailView:
id: str
customer_name: str
items: tuple["OrderItemView", ...]
total: Decimal
status: str
created_at: str
SearchOrders: Buscar Pedidos con Filtros
Para busquedas con multiples criterios y paginacion. Nota que el Read Model para listas (OrderListView) tiene menos campos que el de detalle, solo lo necesario para mostrar en una tabla:
// TypeScript
class SearchOrdersQuery implements Query<PaginatedResult<OrderListView>> {
readonly queryId = crypto.randomUUID();
constructor(
readonly customerId?: string,
readonly status?: string,
readonly fromDate?: Date,
readonly toDate?: Date,
readonly page: number = 1,
readonly pageSize: number = 20
) {}
}
interface OrderListView {
id: string;
customerName: string;
itemCount: number;
total: number;
status: string;
createdAt: string;
}
interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// Go
type SearchOrdersQuery struct {
queryID string
CustomerID *string
Status *string
FromDate *time.Time
ToDate *time.Time
Page int
PageSize int
}
type OrderListView struct {
ID string `json:"id"`
CustomerName string `json:"customerName"`
ItemCount int `json:"itemCount"`
Total float64 `json:"total"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
}
type PaginatedResult[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
TotalPages int `json:"totalPages"`
}
# Python
@dataclass(frozen=True)
class SearchOrdersQuery(Query["PaginatedResult[OrderListView]"]):
customer_id: str | None = None
status: str | None = None
from_date: datetime | None = None
to_date: datetime | None = None
page: int = 1
page_size: int = 20
@dataclass(frozen=True)
class OrderListView:
id: str
customer_name: str
item_count: int
total: Decimal
status: str
created_at: str
@dataclass(frozen=True)
class PaginatedResult(Generic[T]):
items: tuple[T, ...]
total: int
page: int
page_size: int
total_pages: int
GetOrderStats: Obtener Estadisticas y Metricas
Para dashboards y reportes. Este Read Model tiene una estructura completamente diferente, con datos agregados y precalculados:
// TypeScript
class GetOrderStatsQuery implements Query<OrderStatsView> {
readonly queryId = crypto.randomUUID();
constructor(
readonly fromDate: Date,
readonly toDate: Date,
readonly groupBy: "day" | "week" | "month" = "day"
) {}
}
interface OrderStatsView {
totalOrders: number;
totalRevenue: number;
averageOrderValue: number;
ordersByStatus: Record<string, number>;
revenueByPeriod: { period: string; revenue: number }[];
}
// Go
type GetOrderStatsQuery struct {
queryID string
FromDate time.Time
ToDate time.Time
GroupBy string // "day", "week", "month"
}
type OrderStatsView struct {
TotalOrders int `json:"totalOrders"`
TotalRevenue float64 `json:"totalRevenue"`
AverageOrderValue float64 `json:"averageOrderValue"`
OrdersByStatus map[string]int `json:"ordersByStatus"`
RevenueByPeriod []RevenuePeriod `json:"revenueByPeriod"`
}
# Python
@dataclass(frozen=True)
class GetOrderStatsQuery(Query["OrderStatsView"]):
from_date: datetime
to_date: datetime
group_by: Literal["day", "week", "month"] = "day"
@dataclass(frozen=True)
class OrderStatsView:
total_orders: int
total_revenue: Decimal
average_order_value: Decimal
orders_by_status: dict[str, int]
revenue_by_period: tuple[RevenuePeriod, ...]
Read Models Especializados: Un Modelo por Vista
Una de las ventajas de CQRS es que cada vista puede tener su propio Read Model optimizado. No necesitas un modelo unico que sirva para todas las pantallas:
| Query | Read Model | Almacenamiento |
|---|---|---|
| GetOrderById | OrderDetailView | PostgreSQL/MongoDB |
| SearchOrders | OrderListView | Elasticsearch |
| GetOrderStats | OrderStatsView | Redis/Materialized View |
Desnormalizacion: La Clave del Rendimiento
La desnormalizacion es la practica de duplicar datos intencionalmente para acelerar consultas. En una base de datos normalizada, evitamos redundancia. En un Read Model, la abrazamos.
Los Read Models duplican datos para evitar JOINs costosos:
// Write Model (normalizado)
interface Order { customerId: string; items: { productId: string }[] }
// Read Model (desnormalizado)
interface OrderReadModel {
customerId: string;
customerName: string; // Duplicado de Customer
customerEmail: string; // Duplicado de Customer
items: {
productId: string;
productName: string; // Duplicado de Product
productImage: string; // Duplicado de Product
}[];
}
Proximos Pasos
En el siguiente capitulo abordaremos el manejo de consistencia eventual entre Write y Read Models.
Glosario
Query (Consulta)
Definicion: Objeto que representa una solicitud de informacion al sistema. Define que datos se necesitan y con que criterios buscarlos, sin modificar el estado del sistema.
Por que es importante: Separa las solicitudes de lectura en objetos tipados y especificos. Facilita el cache, la validacion y la optimizacion de consultas.
Ejemplo practico: GetOrderByIdQuery contiene solo el orderId. El handler la ejecuta contra el Read Model y retorna un OrderDetailView.
Read Model (Modelo de Lectura)
Definicion: Estructura de datos optimizada para responder consultas especificas. Contiene datos desnormalizados y precalculados, sin logica de negocio.
Por que es importante: Al estar separado del Write Model, puede tener la estructura optima para cada caso de uso de visualizacion, sin preocuparse por escrituras.
Ejemplo practico: OrderDetailView incluye customerName directamente, aunque ese dato vive en otra tabla. Esto evita un JOIN en cada consulta.
View (Vista)
Definicion: En el contexto de CQRS, una vista es la representacion de datos especifica para un caso de uso de lectura. Es sinonimo de Read Model.
Por que es importante: El termino enfatiza que estos modelos estan disenados para ser “vistos” o mostrados, no para ser modificados.
Ejemplo practico: OrderListView tiene campos diferentes a OrderDetailView. La lista muestra menos informacion (para una tabla), el detalle muestra todo (para una pantalla de detalle).
Paginacion
Definicion: Tecnica para dividir grandes conjuntos de datos en paginas manejables. Incluye metadata como pagina actual, tamano de pagina, total de registros y total de paginas.
Por que es importante: Evita cargar todos los registros en memoria. Mejora el rendimiento y la experiencia de usuario al mostrar datos en porciones navegables.
Ejemplo practico: SearchOrdersQuery incluye page y pageSize. PaginatedResult retorna los items de esa pagina mas informacion sobre el total.
Query Handler (Manejador de Consulta)
Definicion: Componente que recibe una Query especifica y ejecuta la logica para obtener los datos solicitados del Read Model.
Por que es importante: Aisla la logica de cada consulta en una clase dedicada. Permite optimizar cada query independientemente.
Ejemplo practico: GetOrderByIdHandler recibe la query, llama al repositorio de lectura y retorna el OrderDetailView o null si no existe.
Datos Precalculados
Definicion: Valores que se calculan al momento de escribir (o proyectar) en lugar de calcularse en cada lectura. Se almacenan ya calculados en el Read Model.
Por que es importante: Elimina calculos repetitivos en consultas. Si un valor se lee 100 veces por cada vez que cambia, tiene sentido calcularlo una vez al escribir.
Ejemplo practico: El campo total en OrderDetailView se precalcula sumando los subtotales de items. No se calcula en cada consulta.