¿Cómo resuelves una consulta que necesita datos de 5 microservicios distintos: composición de APIs vs. CQRS con un modelo de lectura?
Hacer fan-out a cinco llamadas paralelas y fusionarlas, o mantener un modelo de lectura pre-unido construido a partir de eventos — la elección correcta depende de la latencia, la consistencia y la forma de la consulta.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo resuelves una consulta que necesita datos de 5 microservicios distintos: composición de APIs vs. CQRS con un modelo de lectura?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#La pantalla que necesita de todo
Una página de "detalle de pedido" es un ejemplo clásico: necesita el pedido en sí (servicio de pedidos), el nombre del cliente y su nivel de fidelización (servicio de clientes), si los artículos están en stock (servicio de inventario), si el pago se confirmó (servicio de pagos), y la transportista y el número de seguimiento (servicio de envíos). Cinco servicios, una sola pantalla. Hay dos formas fundamentalmente distintas de construir el endpoint que la sirve, y el compromiso entre ellas es una de las decisiones de sistemas distribuidos más prácticas que vas a tomar.
#Opción uno: composición de APIs
Un "composer" — un API gateway, un backend-for-frontend, o un pequeño servicio de agregación dedicado — llama a los cinco servicios, típicamente en paralelo, y fusiona los resultados en una sola respuesta. Es el instinto natural y muchas veces es el correcto: no hay infraestructura nueva, ni un nuevo almacén de datos que mantener sincronizado, y cada campo que devuelves se lee directamente de su sistema de referencia, así que los datos son tan frescos y consistentes como cada servicio pueda ofrecer en ese momento.
// order-composer.ts — Composición de APIs: se hace fan-out y luego se combina.async function getOrderDetails(orderId: string) {const [order, customer, inventory, payment, shipping] = await Promise.all([orderClient.getOrder(orderId),customerClient.getCustomerForOrder(orderId),inventoryClient.getReservation(orderId),paymentClient.getPaymentStatus(orderId),shippingClient.getShipment(orderId),]);return {orderId: order.id,items: order.items,customerName: customer.name,loyaltyTier: customer.loyaltyTier,itemsInStock: inventory.allReserved,paymentStatus: payment.status,carrier: shipping.carrier,trackingNumber: shipping.trackingNumber,};}
El costo aparece bajo carga y ante fallos parciales. La latencia del endpoint queda acotada por la más lenta de las cinco llamadas paralelas, más el paso de fusión — así que tu p99 es efectivamente el p99 combinado de cinco dependencias, que siempre es peor que el de cualquiera de ellas por separado. La disponibilidad se comporta igual: si cada uno de los cinco servicios tiene un 99.9% de disponibilidad de forma independiente, la disponibilidad ingenua del endpoint compuesto ronda el 99.5% (cinco números cercanos a "nueves" multiplicados entre sí se erosionan rápido) a menos que diseñes explícitamente para degradación elegante — devolver datos parciales con un placeholder cuando, digamos, envíos no responde, en lugar de fallar toda la petición. Y la composición se desmorona para cualquier cosa que vaya más allá de "buscar por ID": una consulta como "muéstrame los pedidos de todos los clientes de nivel oro donde algún artículo está agotado" no se puede empujar a través de cinco servicios independientes — tendrías que traer conjuntos amplios de candidatos de cada uno y hacer el join en memoria de la aplicación, lo cual no escala.
#Opción dos: CQRS con un modelo de lectura materializado
CQRS (segregación de responsabilidad de comandos y consultas) separa el lado de escritura (cada servicio sigue siendo dueño de sus propios datos y hace cumplir sus propios invariantes, sin cambios) del lado de lectura, que recibe su propio almacén construido a medida. Un "projector" se suscribe a los eventos de dominio que los cinco servicios ya publican — OrderCreated, PaymentCaptured, InventoryReserved, ShippingLabelCreated — y los va plegando en una única fila o documento desnormalizado por pedido, construido específicamente para responder esta consulta en una sola lectura.
// order-summary-projector.ts// Escucha eventos de dominio de cinco servicios y mantiene una fila// "order_summary" desnormalizada por pedido — construida a medida para// la lectura de detalle de pedido, así la consulta nunca hace fan-out// en tiempo de petición.import { Pool } from "pg";import { KafkaConsumer } from "./kafkaClient";const pool = new Pool({ connectionString: process.env.READ_DB_URL });interface OrderCreatedEvent {orderId: string;customerId: string;itemCount: number;createdAt: string;}interface PaymentCapturedEvent {orderId: string;paymentStatus: "captured" | "failed";amountCents: number;}interface InventoryReservedEvent {orderId: string;}interface ShippingLabelCreatedEvent {orderId: string;carrier: string;trackingNumber: string;}async function upsertOrderSummary(orderId: string, patch: Record<string, unknown>): Promise<void> {const columns = Object.keys(patch);const values = Object.values(patch);const insertCols = ["order_id"].concat(columns).join(", ");const placeholders = columns.map((_, i) => "$" + (i + 2)).join(", ");const setClause = columns.map((c, i) => c + " = $" + (i + 2)).join(", ");const sql ="INSERT INTO order_summary (" + insertCols + ") " +"VALUES ($1, " + placeholders + ") " +"ON CONFLICT (order_id) DO UPDATE SET " + setClause;const params: unknown[] = [orderId, ...values];await pool.query(sql, params);}const consumer = new KafkaConsumer({groupId: "order-summary-projector",topics: ["order.created", "payment.captured", "inventory.reserved", "shipping.label-created"],});consumer.on("order.created", async (event: OrderCreatedEvent) => {await upsertOrderSummary(event.orderId, {customer_id: event.customerId,item_count: event.itemCount,created_at: event.createdAt,status: "created",});});consumer.on("payment.captured", async (event: PaymentCapturedEvent) => {await upsertOrderSummary(event.orderId, {payment_status: event.paymentStatus,amount_cents: event.amountCents,});});consumer.on("inventory.reserved", async (event: InventoryReservedEvent) => {await upsertOrderSummary(event.orderId, {inventory_status: "reserved",});});consumer.on("shipping.label-created", async (event: ShippingLabelCreatedEvent) => {await upsertOrderSummary(event.orderId, {shipping_carrier: event.carrier,tracking_number: event.trackingNumber,status: "shipped",});});
El manejador de la consulta ahora lee de una sola tabla, con una sola búsqueda por índice, sin ningún fan-out:
// order-summary-query.tsapp.get("/orders/:orderId/summary", async (req, res) => {const result = await pool.query("SELECT * FROM order_summary WHERE order_id = $1",[req.params.orderId]);if (result.rows.length === 0) {return res.status(404).json({ error: "not found" });}res.json(result.rows[0]);});
Esta consulta ahora es rápida, barata y — de forma crucial — soporta filtrado, ordenamiento y paginación arbitrarios sobre las cinco fuentes de datos originales, porque es solo una tabla indexada (o un índice de búsqueda como Elasticsearch, si los patrones de consulta necesitan texto completo o facetas). El compromiso es la consistencia eventual: el modelo de lectura va rezagado respecto a los servicios de origen el tiempo que tarde un evento en publicarse, consumirse y aplicarse — normalmente milisegundos o pocos segundos, pero nunca cero. También has asumido infraestructura nueva (el bus de eventos, el projector, el almacén de lectura) y un nuevo modo de fallo: un bug en el projector o un cambio de esquema ahora significa rellenar (backfill) o reconstruir una vista materializada, no solo arreglar un bug en un manejador de peticiones sin estado.
#Cómo elegir en la práctica
Recurre a la composición de APIs cuando la consulta es simple, el número de fuentes es pequeño, la consistencia fuerte importa más que la latencia de lectura en bruto, y prefieres no operar otro almacén de datos. Recurre a CQRS con un modelo de lectura cuando la consulta abarca muchas fuentes con necesidades reales de filtrado y paginación, el volumen de lectura es lo bastante alto como para que la latencia del fan-out y la disponibilidad multiplicada realmente duelan, y el negocio puede tolerar resultados con algunos segundos de desfase. Muchos sistemas reales usan ambos a la vez: composición para una pantalla de administración de bajo tráfico, una vista materializada para la página de cara al cliente que se consulta en cada checkout.
🧠 Check your understanding
0/3 · 0/3 answered1. ¿Por qué la disponibilidad de un endpoint de composición de APIs tiende a ser peor que la de cualquier servicio individual al que llama?
2. ¿Qué debe cumplir la fuente de eventos para que un modelo de lectura materializado pueda reconstruirse de forma segura?
3. ¿Qué escenario favorece más claramente CQRS con un modelo de lectura materializado frente a la composición de APIs?