ADR-023: API Gateway + BFF; REST only at the edge
- Status: Accepted
- Date: 2026-06-18
- Deciders: Principal Architect, Engineering Lead
- Relates to: ADR-007, ADR-013, ADR-018, ADR-020
Context
Five frontends (admin/employer/employee/fi/merchant) call the NestJS API. Edge concerns — authentication, idempotency-key minting (ADR-007), rate limiting, API versioning, tenant routing — are currently scattered across middleware and guards, and the SessionMiddleware.forRoutes(...) registration is a hand-maintained class list that has repeatedly drifted (controllers silently 401). As contexts extract into services (ADR-018), the question of where clients enter and how edge policy is applied needs a single answer.
Decision
There is one explicit edge layer that owns auth + idempotency + rate-limit + API versioning. REST exists only at the edge (frontends ↔ gateway, and future partner-facing public APIs). Internal service-to-service communication is gRPC for synchronous calls and events for asynchronous ones (ADR-020) — never internal REST.
Progressive, per ADR-018:
- MVP: the edge layer is a clearly-named layer inside the existing NestJS app (global auth that respects
@Public, idempotency minting, rate-limit, a/api/v1version prefix) — not a separate deployable. The hand-maintained middleware list is replaced by global application + a CI guard so coverage can't drift. - Post-MVP: extract per-audience BFFs (employer/admin/employee/partner) and/or a standalone gateway when frontend-shaped aggregation or independent edge scaling justifies it. The edge contract is documented so this lift touches no domain code.
Alternatives considered
- Separate gateway + BFFs from day one — rejected: ops overhead before launch; the edge responsibilities can live as one app layer first.
- Keep edge concerns scattered + the manual middleware list — rejected: it is the single most-stepped-on drift hazard in the repo (auth coverage gaps ship silently).
Consequences
- Positive: one place for edge policy; auth coverage can't silently drift (CI-guarded); clean seam to lift a real gateway/BFF out later; internal calls stay typed (gRPC), not untyped REST.
- Negative / accepted: at MVP the "gateway" is logical, not a separate process — some will read the diagram as more than it is; the BFF aggregation benefit is deferred.
- Follow-ups: consolidate edge concerns into one module; replace
forRoutes(...)with global-minus-@Public+ a coverage guard; add the/api/v1prefix.
Revisit when
- Frontend-shaped aggregation or independent edge scaling is needed → extract per-audience BFFs / a standalone gateway behind the documented edge contract.