Anonymized writeup. Client name omitted under non-compete. Architecture and stack patterns are fully discussable.
Context
Tier-1 LATAM multi-channel retailer. Four consumer surfaces: native mobile (iOS + Android), responsive customer web, admin console for store operators, physical-POS at flagship stores. Inventory spans an online warehouse + ~150 physical stores with independent stock pools. Payment side: 4 local LATAM acquirers + 1 international card network. Loyalty earns/burns across every channel.
Stack reality pre-decomposition: Rails monolith, 8+ years old, 1M+ LOC, 40+ engineers shipping into it, 30-min deploy cycle gated by manual QA, one shared Postgres with every team’s tables intermixed. Peak load: ~250 orders/min, ~4 events/order fanned out downstream.
Problem statement
Three concrete problems forcing decomposition:
- Team-autonomy ceiling. 40+ engineers + 1 deploy pipeline = deploy-lock contention. Mobile waits on loyalty. Payments cannot ship an acquirer fix without admin-console changes piggy-backing in. Lead-time-for-changes had crept past
5 daysmedian (DORA 2024 elite tier is< 1 day). - Channel fragmentation. Mobile wants
customer + inventory@store + loyalty-summarypre-joined per screen. Admin wants the raw entity tree + audit metadata. POS wants offline-first with eventual sync. One API surface serving all three = lowest-common-denominator design; 3 of 4 channels paid an over-fetch tax on every request. - Transactional consistency under partial failure.
order-placedfans out to payment-capture + inventory-reserve + loyalty-accrue + ship-label + warehouse-pick — all-or-none. An acquirer timeout at 03:00 BRT cannot leave a paid order without reserved inventory.
Constraints
- Checkout
p99 ≤ 500msend-to-end (mobile page-load perception threshold) - Payment-provider outages must NOT cascade to customer-visible errors — retry + degrade gracefully
- Single end-to-end trace across all services for finance audit replay (Central Bank card-acquirer reconciliation, BACEN Circular 3.978/2020 )
- Zero lost events: at-least-once delivery via Outbox + idempotent consumers
- Per-channel team ships independently — no shared deploy lock
- Language pragma: TypeScript across BFFs (FE/BE shared types + hire-pool); Go for Broker + Dispatcher (static binary +
~5msper-event overhead); Rails monolith decommissioned incrementally, never flag-day
Architecture
fig. 2 — event flow · BFF → Core → outbox → Broker → Kafka → Dispatcher
Six components carry most of the weight:
- BFF-per-channel. Each channel team owns a thin TypeScript / NestJS BFF that aggregates Core into the exact payload its frontend needs. Mobile pre-joins
customer + inventory@my-store + loyalty-summaryin one round-trip; Admin emits the raw entity tree + audit metadata; POS supports offline-first via local SQLite + sync-on-reconnect. Each BFF stays< 15 KLOC, deployed independently by its channel team. - Core services. Orders, payments, inventory, loyalty, shipping. Each owns its own Postgres schema with zero cross-schema FKs. Public contract: protobuf-defined gRPC (Core ↔ Core) + JSON/REST (BFF ↔ Core). Every Core service stays
< 20 KLOCso one engineer holds the whole model in head. - Outbox table. Every Core write commits domain rows + outbox row in the same transaction:
BEGIN; UPDATE orders SET status='placed'; INSERT INTO outbox(event_id, type, payload, created_at); COMMIT;. Atomicity guarantees a successful domain write will produce an event. - Broker. Go service polls each outbox at
200ms(or subscribes via Postgres logical replication where p99-latency-sensitive), publishes to Kafka withpartition_key = order_idfor per-order ordering. Broker is the ONLY component allowed to read outbox tables — no direct-publish from Core. - Dispatcher. Stateless Go consumer. Calls downstream Core services with
idempotency-key = event_id(UUIDv7). Retry: exponential backoff100ms → 30sover 8 attempts, then route to DLQ. All retry state lives in Kafka offsets + a24h-TTLRedis attempt counter — Dispatcher itself stores nothing. - DLQ + replay UI. Ops inspects failed payloads, traces the full saga chain via
trace_id, re-queues or marks-resolved. Replay preserves the originalidempotency-keyso downstream never double-processes. No event is ever truly lost.
Decision narrative
Four decisions worth calling out — each with explicit trade-offs.
Decision 1: BFF-per-channel over single API gateway.
A single gateway (Kong
, AWS API Gateway
) would centralize ops + flatten the deploy story. Rejected because the real pain wasn’t routing — it was payload shape. Every channel wanted different joins; a gateway can’t do that without custom transform logic that drifts into BFF territory anyway. Per-channel BFF makes team boundaries explicit and lets Mobile ship 12×/week while Admin ships 2×/week without coordination. Pattern grounded in Sam Newman’s BFF writeup
and the Newman + O’Reilly BFF chapter, Building Microservices 2nd ed. ch.4
. Reconsider when the team has only 1 consumer surface — at that point the BFF is overhead, not abstraction.
Decision 2: Outbox over direct-publish + dual-write.
Dual-write (app commits DB, then publishes to Kafka in a separate step) is race-condition central: DB commit + broker crash = silent lost event. Outbox stores the event in the same DB transaction as the domain write; Broker publishes from outbox with at-least-once delivery; downstream deduplicates on idempotency-key. Cost: median publish latency rises from ~5ms (direct) to ~120ms (poll interval 200ms + replication lag). Benefit: zero lost events across ~18 months of production traffic. Pattern documented in Chris Richardson’s Microservices Patterns, ch.3
. Reconsider the polling interval (drop to 50ms or switch to logical replication) when end-to-end fan-out latency starts trespassing on the 500ms checkout budget.
Decision 3: Saga over 2-phase commit for multi-service transactions.
order-placed touches 5 services (orders + payments + inventory + loyalty + shipping). 2PC would lock all 5 during commit — availability disaster the moment any one is slow. Saga: an orchestrated sequence of local transactions with compensating actions on failure (order-cancel reverses payment-capture, restores inventory hold, claws back loyalty points). Cost: every action needs a designed compensation, which roughly doubles the design effort per workflow. Benefit: per-service availability is unlocked, and any one downstream’s 5xx only escalates the saga to compensate — never blocks the user. Grounded in Garcia-Molina + Salem’s original SAGAS paper, SIGMOD 1987
— still the load-bearing reference 39 years later. Reconsider only for hard-real-time semantic invariants (e.g. live auction bid acceptance) where eventual consistency is unacceptable.
Decision 4: Strangler-fig over big-bang rewrite.
The Rails monolith was NOT rewritten in a single cutover. Instead, Fowler’s strangler-fig pattern
: new code lives in Core services behind BFFs, old code stays in monolith, routes migrate per-feature behind feature-flags over ~18 months.Cost: temporary duplication (a feature lives in both monolith + Core during cutover). Benefit: zero flag-day risk, and any individual feature migration can be reverted in < 5 min by toggling its flag.
Lessons learned
- Idempotency keys are first-class, not a retrofit. Every event carries a UUIDv7. Every downstream persists
(idempotency_key, result)in a24h-TTLdedup table. Retry becomes boring. - Outbox-poller drift is the single failure mode worth alerting on.
oldest_unprocessed_outbox_age > 60sper service catches Broker stalls before customers notice. Ours fired exactly twice in 18 months — both real, both pre-empted. - BFF-per-channel drifts toward duplication; tend it actively. Mobile + Web BFFs shared
~70%of the customer-profile query. Extracted into a versioned@company/customer-clientpackage, re-linted every sprint. Team autonomy without active de-duplication = three copies of the same query. - DLQ replay UI is the saga’s invariant guarantee. No event is ever truly lost; ops can always reconstruct the chain from
order-placedthrough the failed step. Audits become a one-query reconciliation instead of a forensic. - Trace-id propagation is binary — you have it everywhere or nowhere. One missing hop = a 3-hour debug session. OpenTelemetry baked into BFF middleware + gRPC interceptors + Kafka headers from day 1. Bolt-on later costs 10× more.
- Anti-pattern to refuse on sight: a single shared client SDK across all four channels. It looks DRY for one sprint, then becomes the new shared deploy lock — exactly the thing BFF-per-channel exists to dissolve.
Stack
- BFF layer: TypeScript 5 + Node.js 20 + NestJS 10 , gRPC clients to Core, Zod for runtime validation at every BFF↔Core boundary
- Core services: TypeScript + Node for CRUD-heavy (orders, loyalty); Go 1.22 for perf-sensitive (payments, inventory); Postgres 14 with per-service schema + zero cross-schema FKs
- Broker + Dispatcher: Go 1.22 + IBM/Sarama Kafka client, stateless, horizontally scaled per-topic
- Event bus: Kafka (managed Confluent Cloud
) +
partition_key = order_idfor per-order ordering - Dedup store: Redis 7 —
24hTTL on idempotency keys + per-event attempt counter - Observability: OpenTelemetry → Honeycomb
(trace-first), Grafana dashboards for
saga_health,outbox_age_p99,dlq_depth - Deploy: Kubernetes (GKE) + Helm per-service, GitHub Actions matrix CI, progressive rollout via Argo Rollouts
- Migration: strangler-fig over
~18 months, every route migration feature-flagged + reversible in< 5 min
When this pattern fits
- Multi-channel commerce / fintech / loyalty: ≥ 3 distinct consumer surfaces with diverging payload shapes
- ≥ 5 independent engineering teams reaching deploy-lock contention (DORA lead-time
> 1 day) - Transactional consistency with availability priority (saga acceptable, 2PC not)
- Financial audit-replay requirement — the event log gives you that for free
When it doesn’t
- Single-channel SaaS. BFF-per-channel is pure tax; one well-versioned REST API is fine.
- Team < 8 engineers per BFF. Below that, dependency churn dominates and the channel team can’t sustain a service surface without burning out.
- Strict synchronous semantic requirement (e.g. real-time auction bid acceptance, regulated batch jobs). Saga’s eventual consistency isn’t acceptable.
< 100 KLOCmonolith with< 10engineers. A modular monolith with selective event seams is cheaper to operate and easier to reason about — the strangler-fig payoff doesn’t materialize until you cross the team-coordination threshold.