Relato anonimizado. El nombre del cliente queda fuera por el acuerdo de no competencia — pero la arquitectura y los patrones de stack están del todo abiertos a discusión.
El escenario
Un minorista multicanal LATAM de primera línea. Cuatro frentes de consumo conviviendo a la vez: móvil nativo (iOS y Android), web responsiva para el cliente, consola de administración para los operadores de tienda y POS físico en las tiendas insignia. El inventario vive en dos mundos — un almacén en línea más unas 150 tiendas físicas, cada una con su pool de stock propio. Del lado del dinero, 4 adquirentes locales de la región y una red internacional de tarjetas. Y, cosiéndolo todo, un programa de fidelidad que acumula y canjea puntos en cualquier canal.
El stack que heredamos antes de tocar nada: un monolito Rails con más de 8 años encima, 1M+ LOC, más de 40 ingenieros entregando dentro de él a diario, un deploy de 30-min atascado tras QA manual y un único Postgres donde las tablas de cada equipo se mezclaban sin orden. En el pico, ~250 orders/min que generaban ~4 events/order para repartir downstream.
Lo que estaba apretando
La presión por desacoplar venía de tres dolores muy concretos.
El primero era el techo de autonomía de los equipos. Junta a más de 40 ingenieros con un solo pipeline de deploy y lo que queda es contención de deploy-lock: el móvil queda rehén de fidelidad, y pagos no logra soltar el arreglo de un adquirente sin que cambios de la consola de administración se cuelen de polizón. El lead-time-for-changes ya había rebasado los 5 days de mediana — lejísimos del tier de élite de DORA 2024, que cierra en < 1 day
.
El segundo era la fragmentación entre canales. Cada frente pide una forma de payload distinta: el móvil quiere customer + inventory@store + loyalty-summary ya pre-unidos por pantalla; el admin reclama el árbol de entidades crudo con sus metadatos de auditoría; el POS necesita offline-first con sincronización eventual. Servir a los tres desde una única superficie de API solo da en el mínimo común denominador — y 3 de los 4 canales pagaban impuesto de over-fetch en cada petición.
El tercero, y el más espinoso, era la consistencia transaccional cuando algo falla a medias. Un order-placed se abre en payment-capture, inventory-reserve, loyalty-accrue, ship-label y warehouse-pick, y eso tiene que ser todo-o-nada. Un timeout de adquirente a las 03:00 BRT no puede terminar con un pedido pagado y el inventario jamás bloqueado.
Las reglas del juego
- Checkout con
p99 ≤ 500msde punta a punta — es el umbral en que el usuario todavía siente la página del móvil como instantánea. - Una caída de proveedor de pago no puede convertirse en un error en la cara del cliente: la respuesta es hacer retry y degradar con elegancia.
- Un único trace de punta a punta que atraviese todos los servicios, para sostener el replay de auditoría financiera — la reconciliación de adquirente de tarjetas que exige el Banco Central (Circular BACEN 3.978/2020 ).
- Perder un evento es inaceptable: entrega at-least-once vía Outbox, con consumidores idempotentes del otro lado.
- Cada equipo de canal entrega a su propio ritmo, sin deploy-lock compartido.
- Elección de lenguaje con criterio: TypeScript en los BFFs (tipos compartidos entre front y back, más un pool de contratación más amplio); Go en el Broker y el Dispatcher (binario estático y
~5msde overhead por evento); y el monolito Rails jubilándose de a poco, nunca en un flag-day.
La arquitectura
fig. 2 — flujo de eventos · BFF → Core → outbox → Broker → Kafka → Dispatcher
Son seis piezas las que sostienen el diseño:
- BFF-por-canal. Cada equipo de canal cuida su propio BFF delgado, en TypeScript / NestJS, que agrega el Core en el payload exacto que ese frontend pide — nada de más. El móvil une
customer + inventory@my-store + loyalty-summaryen un solo round-trip; el admin devuelve el árbol de entidades crudo con los metadatos de auditoría; el POS corre offline-first sobre SQLite local y sincroniza al reconectar. Cada BFF se queda por debajo de< 15 KLOCy el deploy lo hace el propio equipo, cuando quiera. - Servicios de Core. Orders, payments, inventory, loyalty, shipping — cada uno dueño de su schema Postgres, sin ninguna FK cruzando fronteras. El contrato público es gRPC definido en protobuf entre Core y Core, y JSON/REST entre BFF y Core. La meta es mantener cada servicio por debajo de
< 20 KLOC, en el tamaño donde un solo ingeniero todavía sostiene el modelo entero en la cabeza. - Tabla Outbox. Toda escritura del Core graba las filas de dominio y la fila de outbox dentro de la misma transacción:
BEGIN; UPDATE orders SET status='placed'; INSERT INTO outbox(event_id, type, payload, created_at); COMMIT;. La atomicidad es la garantía: si la escritura de dominio pasó, el evento va a existir. - Broker. Un servicio en Go que hace polling de cada outbox cada
200ms— o se suscribe vía replicación lógica de Postgres donde la latencia de p99 aprieta — y publica en Kafka conpartition_key = order_id, conservando el orden dentro de cada pedido. Y es el único componente con permiso de leer las tablas outbox: publicación directa desde el Core, jamás. - Dispatcher. Consumidor Go, del todo stateless. Llama a los servicios de Core downstream cargando
idempotency-key = event_id(UUIDv7). La política de retry es backoff exponencial de100ms → 30spor 8 intentos y, agotados estos, manda a la DLQ. El estado de retry entero vive en los offsets de Kafka y en un contador de intentos en Redis con24h-TTL— el Dispatcher en sí no guarda nada. - DLQ + replay UI. Es donde el equipo de ops abre los payloads que fallaron, reconstruye la cadena entera de la saga por el
trace_idy decide entre re-encolar o marcar como resuelto. El replay conserva laidempotency-keyoriginal, así que el downstream nunca procesa dos veces. Ningún evento se pierde de verdad.
Por qué cada decisión salió así
Vale la pena abrir cuatro elecciones — y, en cada una, dejar el trade-off sobre la mesa.
Decisión 1: BFF-por-canal, y no un API gateway único.
Un gateway central (Kong
, AWS API Gateway
) centralizaría la operación y dejaría la historia de deploy más simple. Lo descartamos porque el dolor real nunca fue el ruteo — era la forma del payload. Cada canal quería un conjunto de joins distinto, y un gateway solo lo resuelve a fuerza de lógica de transformación personalizada, que tarde o temprano termina siendo un BFF disfrazado. Con un BFF por canal, la frontera entre equipos queda explícita y el móvil entrega 12×/week mientras el admin entrega 2×/week, sin que nadie tenga que coordinar nada. El cimiento del patrón está en el texto de BFF de Sam Newman
y en el capítulo dedicado de Building Microservices, 2ª ed., cap.4, de O’Reilly
. Conviene repensarlo cuando el producto tiene solo 1 superficie de consumo — ahí el BFF deja de ser abstracción y pasa a ser peso muerto.
Decisión 2: Outbox, y no publicación directa con dual-write.
El dual-write — la aplicación hace commit en la base de datos y, en un segundo paso, publica en Kafka — es una fábrica de condiciones de carrera: basta con que el commit pase y el broker se caiga para que el evento desaparezca en silencio. El Outbox cierra esa brecha grabando el evento en la misma transacción que la escritura de dominio; el Broker publica desde el outbox con entrega at-least-once, y el downstream deduplica por la idempotency-key. El precio es latencia: la mediana de publicación sube de ~5ms en la vía directa a ~120ms (intervalo de polling de 200ms más el lag de replicación). A cambio, fueron ~18 months de tráfico en producción con cero eventos perdidos. Es el patrón que Chris Richardson documenta en Microservices Patterns, cap.3
. El disparador para revisar el intervalo de polling — bajarlo a 50ms o migrar de plano a replicación lógica — es el momento en que la latencia de fan-out de punta a punta empieza a comerse el presupuesto de 500ms del checkout.
Decisión 3: Saga, y no commit en dos fases, para transacciones multiservicio.
Un order-placed toca 5 servicios (orders, payments, inventory, loyalty y shipping). El 2PC bloquearía los cinco durante el commit — desastre de disponibilidad en el segundo en que uno de ellos se atasque. La Saga lo resuelve por otro camino: una secuencia orquestada de transacciones locales, cada una con su acción compensatoria cuando algo sale mal (el order-cancel revierte el payment-capture, libera el inventario bloqueado y reversa los puntos de fidelidad). El costo es que toda acción necesita una compensación diseñada a mano, lo que prácticamente duplica el esfuerzo de diseño por workflow. A cambio, la disponibilidad de cada servicio queda libre, y un 5xx de cualquier downstream apenas empuja la saga al compensate — nunca bloquea al usuario. La base sigue siendo el artículo original SAGAS de Garcia-Molina y Salem, SIGMOD 1987
, que aún sostiene el tema 39 años después. Solo lo repensaría ante una invariante semántica de tiempo real duro — aceptar una oferta en una subasta en vivo, por ejemplo — donde la consistencia eventual no cabe.
Decisión 4: Strangler-fig, y no la reescritura big-bang.
El monolito Rails nunca se reescribió en un corte único. El camino fue el patrón strangler-fig de Fowler
: código nuevo naciendo en los servicios de Core detrás de los BFFs, código viejo siguiendo vivo en el monolito, y las rutas migrando función por función detrás de feature-flags a lo largo de ~18 months.El precio es duplicación temporal: durante el cutover, la función existe en el monolito y en el Core a la vez. La ganancia es no tener riesgo de flag-day alguno — y poder revertir la migración de cualquier función aislada en < 5 min, con solo apagar su flag.
Lo que quedó de aprendizaje
- La clave de idempotencia es ciudadana de primera clase — no se puede encajar después. Todo evento carga un UUIDv7, y todo downstream persiste
(idempotency_key, result)en una tabla de dedup con24h-TTL. Con eso, el retry se vuelve cosa aburrida. - La deriva del outbox-poller es el único modo de falla que merece una alerta. Un
oldest_unprocessed_outbox_age > 60spor servicio detecta al Broker atascándose antes de que el cliente lo sienta. El nuestro se disparó exactamente dos veces en 18 meses — las dos reales, las dos atajadas a tiempo. - El BFF-por-canal tira naturalmente hacia la duplicación; hay que podarlo siempre. Los BFFs de móvil y web llegaron a compartir
~70%de la query de perfil del cliente. Lo extrajimos a un paquete versionado,@company/customer-client, y lo re-lintamos cada sprint. Autonomía de equipo sin de-duplicación activa termina en tres copias de la misma query. - La replay UI de la DLQ es la garantía de la invariante de la saga. Como nada se pierde de verdad, ops siempre reconstruye la cadena desde
order-placedhasta el paso que falló. La auditoría deja de ser pericia forense y se vuelve reconciliación de una sola query. - La propagación de trace-id es todo o nada — o está en cada hop, o no está en ninguno. Un solo salto sin ella cuesta una sesión de debug de 3 horas. Por eso el OpenTelemetry entró desde el día 1, embebido en el middleware del BFF, en los interceptors de gRPC y en los headers de Kafka. Intentar pegarlo después sale 10× más caro.
- Anti-patrón para frenar de inmediato: un único SDK de cliente compartido por los cuatro canales. Parece DRY por un sprint y enseguida se vuelve el nuevo deploy-lock compartido — justo el nudo que el BFF-por-canal existe para desatar.
El stack
- Capa de BFF: TypeScript 5 + Node.js 20 + NestJS 10 , clientes gRPC hacia el Core y Zod validando en runtime cada frontera BFF↔Core.
- Servicios de Core: TypeScript + Node donde el peso es CRUD (orders, loyalty); Go 1.22 donde importa la performance (payments, inventory); Postgres 14 con schema por servicio y cero FK entre schemas.
- Broker + Dispatcher: Go 1.22 con el cliente Kafka IBM/Sarama , stateless y escalado horizontalmente por tópico.
- Bus de eventos: Kafka gestionado en Confluent Cloud
, con
partition_key = order_idgarantizando el orden por pedido. - Store de dedup: Redis 7 — TTL de
24hen las claves de idempotencia más el contador de intentos por evento. - Observabilidad: OpenTelemetry → Honeycomb
en un enfoque trace-first, con dashboards en Grafana para
saga_health,outbox_age_p99ydlq_depth. - Deploy: Kubernetes (GKE) con Helm por servicio, CI en matriz en GitHub Actions y rollout progresivo vía Argo Rollouts .
- Migración: strangler-fig a lo largo de
~18 months, con todo cambio de ruta detrás de feature-flag y reversible en< 5 min.
Cuándo vale la pena este patrón
- Comercio, fintech o fidelidad multicanal: a partir de 3 superficies de consumo distintas, cada una tirando de un formato de payload diferente.
- A partir de 5 equipos de ingeniería independientes ya chocando con la contención de deploy-lock (lead-time de DORA
> 1 day). - Cuando la consistencia transaccional tiene que convivir con prioridad de disponibilidad — la saga sirve, el 2PC no.
- Cuando hay exigencia de replay de auditoría financiera: el log de eventos ya lo entrega de regalo.
Cuándo conviene dejarlo pasar
- SaaS de canal único. Ahí el BFF-por-canal es puro impuesto; una API REST bien versionada alcanza.
- Equipo con menos de 8 ingenieros por BFF. Por debajo de eso el churn de dependencias domina, y el equipo de canal no sostiene una superficie de servicio sin quemarse.
- Exigencia semántica estrictamente síncrona (aceptar una oferta en una subasta en tiempo real, jobs en lote regulados). La consistencia eventual de la saga no cabe.
- Monolito de
< 100 KLOCcon< 10ingenieros. Un monolito modular con algunas costuras de evento bien elegidas es más barato de operar y más fácil de razonar — el retorno del strangler-fig solo aparece después de que cruzas el umbral de la coordinación entre equipos.