Split a system into services and the client inherits the split. A screen that used to need one query now needs six calls to four services, assembled in the browser, over a mobile connection, with six chances to fail and a waterfall of dependent requests. The user experience gets worse in direct proportion to how cleanly you separated the backend.
Two patterns address this and are routinely conflated. An API gateway is infrastructure: routing, authentication, rate limiting, TLS. A backend for frontend is an application: it aggregates and shapes data for one specific client. Putting BFF responsibilities into gateway configuration is how organisations end up with business logic in a routing layer that nobody owns and nothing tests.
One BFF per client, owned by the client team
The web application and the mobile application want different things. Mobile wants fewer, larger, denser responses because round trips are expensive and the screen is small. Web can afford more calls and wants richer detail. A single shared API serving both converges on a compromise that is inefficient for one and awkward for the other.
The ownership point is what makes it work. The team that builds the mobile client owns the mobile BFF, so changing what a screen needs is one team's change in one repository, not a request to a backend team with its own roadmap. If the BFF is owned by a platform team, you have added a hop and kept the coordination cost.
# The BFF's job: fan out in parallel, degrade gracefully, return one shape
# that matches the screen.
@app.get('/mobile/v1/inspection/{id}/screen')
async def inspection_screen(id: str, user=Depends(current_user)):
async with asyncio.TaskGroup() as tg:
inspection = tg.create_task(inspections.get(id, actor=user))
photos = tg.create_task(media.thumbnails(id, size='mobile'))
# Optional data must never fail the screen.
weather = tg.create_task(soft(weather_api.for_site(id), default=None))
history = tg.create_task(soft(audit.recent(id, limit=5), default=[]))
return MobileInspectionScreen(
title=inspection.result().reference,
photos=[p.url for p in photos.result()],
weather=weather.result(), # renders if present, omitted if not
recent_activity=history.result(),
)The soft wrapper is the pattern that matters most in practice. A screen should render without the optional panel when a secondary service is unavailable; without that distinction between required and optional data, a BFF makes availability worse than direct calls, because now one failing dependency fails everything.
Keeping it from becoming a monolith
- No business rules in the BFF. It aggregates, shapes and caches; decisions belong in the services that own the data.
- No direct database access. The moment a BFF queries a service's tables, the service boundary is gone.
- Keep the gateway to cross-cutting concerns — auth, rate limiting, routing. Aggregation is application code and belongs in an application.
- Deploy the BFF with its client. They change together, so releasing them together removes a whole class of version mismatch.
- Watch for duplicated aggregation across BFFs; if three of them build the same view, that view probably belongs in a service.
A backend for frontend is a client-side concern that happens to run on a server. When it starts making business decisions, it has become a service nobody agreed to build.
Worth saying plainly: if you have one client, you do not need this. A single web application talking to a well-designed API should just do that. The pattern earns its keep when you genuinely have multiple clients with divergent needs, and adopting it before that point buys you an extra deployable, an extra hop, and an extra place for logic to hide.