Diagrama de arquitectura — backend de retail orientado a eventos con BFFs por canal, broker transaccional, dispatcher como side-car y topología de tres planos

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 ≤ 500ms de 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 ~5ms de overhead por evento); y el monolito Rails jubilándose de a poco, nunca en un flag-day.

La arquitectura

Flujo de eventos: cuatro apps de canal a través de los BFFs por canal hacia los Core services; el servicio de orders escribe en la outbox table en la misma transacción; el Broker hace poll de la outbox y publica en Kafka; el Dispatcher consume y llama a payments, inventory, loyalty y shipping con idempotency keys, derivando fallas a la DLQ con replay

fig. 2 — flujo de eventos · BFF → Core → outbox → Broker → Kafka → Dispatcher

Son seis piezas las que sostienen el diseño:

  1. 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-summary en 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 KLOC y el deploy lo hace el propio equipo, cuando quiera.
  2. 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.
  3. 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.
  4. 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 con partition_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.
  5. 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 de 100ms → 30s por 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 con 24h-TTL — el Dispatcher en sí no guarda nada.
  6. DLQ + replay UI. Es donde el equipo de ops abre los payloads que fallaron, reconstruye la cadena entera de la saga por el trace_id y decide entre re-encolar o marcar como resuelto. El replay conserva la idempotency-key original, 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 con 24h-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 > 60s por 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-placed hasta 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_id garantizando el orden por pedido.
  • Store de dedup: Redis 7 — TTL de 24h en 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_p99 y dlq_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 KLOC con < 10 ingenieros. 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.