Philipp Lehmann @PhilippTheServer

One Dashboard on Five Unrelated Backends: Normalising at the Boundary

Why a BFF is a translation layer, not a proxy, and how to make its failures partial.

The problem

A portal surfacing open tickets, time tracked and CI status needs data from three systems never designed to sit next to each other. The obvious first step is a FastAPI route per upstream that forwards the request and returns whatever comes back:

# Broken. Do not copy this.
import httpx
from fastapi import FastAPI

app = FastAPI()

@app.get("/api/tickets")
async def tickets():
    async with httpx.AsyncClient() as client:
        response = await client.get("https://tickets.example.com/api/v1/issues")
        return response.json()

A second route does the same for the CI system, and agrees with the first on almost nothing. Ticketing paginates with page, per_page and has_more; CI with an opaque cursor. Ticketing calls the status field “state” (open, in-progress, resolved, closed); CI calls the same concept “result” (running, passed, failed). One authenticates with an API key in the query string, the other with a bearer token. Neither is a bug — different teams built these, years apart. The bug is assuming a thin proxy is a design.

Every one of those differences now lives in the frontend, because the route only changed the URL:

  • a pagination helper per upstream, because has_more and cursor need different loops
  • a status-mapping table per upstream, because state and result speak different vocabularies
  • the CI system’s bearer token sitting in code the browser can read, forwarded verbatim
  • a third full set of these the day a time-tracking upstream joins the portal

None of this produces an incident, which is why it accumulates unnoticed. What gets noticed is a blank dashboard the day one upstream times out — a rejected promise took the whole render tree down, because nothing decided otherwise.

Working through it

Why pass-through proxying leaks upstream inconsistency

A route that forwards a request and returns the response verbatim relocates where the network call originates; it does not change what the frontend has to understand. It depended on N upstream schemas before the proxy existed, and still does after, just reached through one hostname instead of three.

A backend-for-frontend earns its name only when it does what a proxy does not: pick a shape belonging to the frontend and translate every upstream into it. The adapter absorbs pagination, field names and auth scheme; the frontend sees none of it.

Designing a normalised internal schema

The schema is not the union of every field every upstream exposes. It is the smallest shape the frontend needs, decided independently of any single upstream:

class WorkItem(BaseModel):
    id: str
    source: WorkItemSource
    title: str
    status: WorkItemStatus
    url: str
    updated_at: datetime

WorkItemStatus is a closed enum — open, in_progress, done, failed — and each adapter maps its upstream’s vocabulary onto it once, by whoever understands that upstream best. The alternative is a raw status string, mapped instead by whichever frontend engineer next renders a badge.

Every adapter satisfies one contract:

class UpstreamAdapter(ABC):
    source: WorkItemSource

    @abstractmethod
    async def fetch(self, client: httpx.AsyncClient) -> list[WorkItem]:
        raise NotImplementedError

Adding a fourth upstream — a time-tracking API, say — means writing one class that returns list[WorkItem], not touching the aggregator or the frontend.

Handling partial failure

A naive proxy never answers what happens when one upstream is unreachable. Left unanswered, the answer is “whatever an unhandled exception does” — usually the whole response failing regardless of how many upstreams stayed healthy.

The adapters below do not catch their own errors — raise_for_status() is left to raise. Partial failure is decided in one place, the aggregator:

async def gather_dashboard(client, adapters):
    results = await asyncio.gather(
        *(adapter.fetch(client) for adapter in adapters),
        return_exceptions=True,
    )
    ...

return_exceptions=True turns a raised exception into a value in the results list instead of cancelling the other, still-running calls. A failed adapter contributes no items and one UpstreamHealth entry marked unavailable; the response still carries HTTP 200.

The cost: a monitoring setup checking only status codes will not notice an upstream down for a week if every response stays a 200. upstream_health gives something — the frontend, a synthetic check — somewhere to look. A graceful degrade nobody watches just hides an outage more slowly.

Caching and aggregation cost

Each dashboard load can mean several sequential requests per adapter once pagination is accounted for, multiplied by however many people have it open. That multiplier argues for caching at the aggregation boundary rather than per upstream: it sees total request volume, and one cached response there serves every viewer.

A single BFF instance can hold this in process — a dictionary keyed by nothing, since there is one dashboard, refreshed on a short TTL with an asyncio.Lock around the refresh so concurrent misses do not all hit every upstream at once. That is enough for one instance. Behind more than one process it stops being coherent — instances degrade and recover independently — and a shared cache becomes necessary, worth adding only once a second instance is actually planned.

Testing adapters in isolation

httpx.MockTransport intercepts requests before they reach a socket and hands back whatever response a plain function decides. A test never touches a network or the real upstream’s availability — it only has to construct the JSON that upstream returns and check what the adapter turns it into. respx is worth reaching for once a test needs to stub several distinct URLs with different behaviour per call; for one endpoint per adapter, a plain function is one less dependency for the same result.

The aggregator is tested the same way, one level up: dependency_overrides replaces the shared httpx.AsyncClient with one wired to a mock transport, and the request goes in through ASGITransport — in process, no network — exercising the full path.

The solution

Python 3.11–3.13 (the pinned pydantic-core has no wheel for newer interpreters yet):

# requirements.txt
fastapi==0.115.0
httpx==0.27.2
pydantic==2.9.2
uvicorn==0.30.6
pytest==8.3.3
pytest-asyncio==0.24.0
# app/models.py
from datetime import datetime
from enum import Enum

from pydantic import BaseModel


class WorkItemSource(str, Enum):
    ticketing = "ticketing"
    ci = "ci"


class WorkItemStatus(str, Enum):
    open = "open"
    in_progress = "in_progress"
    done = "done"
    failed = "failed"


class WorkItem(BaseModel):
    id: str
    source: WorkItemSource
    title: str
    status: WorkItemStatus
    url: str
    updated_at: datetime


class UpstreamHealth(BaseModel):
    source: WorkItemSource
    available: bool
    error: str | None = None


class DashboardResponse(BaseModel):
    items: list[WorkItem]
    upstream_health: list[UpstreamHealth]
# app/adapters/base.py
from abc import ABC, abstractmethod

import httpx

from app.models import WorkItem, WorkItemSource


class UpstreamAdapter(ABC):
    source: WorkItemSource

    @abstractmethod
    async def fetch(self, client: httpx.AsyncClient) -> list[WorkItem]:
        raise NotImplementedError
# app/adapters/tickets.py
from datetime import datetime

import httpx

from app.adapters.base import UpstreamAdapter
from app.models import WorkItem, WorkItemSource, WorkItemStatus

_STATUS_MAP = {
    "open": WorkItemStatus.open,
    "in-progress": WorkItemStatus.in_progress,
    "resolved": WorkItemStatus.done,
    "closed": WorkItemStatus.done,
}


class TicketingAdapter(UpstreamAdapter):
    source = WorkItemSource.ticketing

    def __init__(self, base_url: str, api_key: str):
        self.base_url = base_url
        self.api_key = api_key

    async def fetch(self, client: httpx.AsyncClient) -> list[WorkItem]:
        items: list[WorkItem] = []
        page = 1
        while True:
            response = await client.get(
                f"{self.base_url}/issues",
                params={"page": page, "per_page": 50, "api_key": self.api_key},
            )
            response.raise_for_status()
            payload = response.json()
            for raw in payload["results"]:
                items.append(
                    WorkItem(
                        id=f"ticket-{raw['key']}",
                        source=self.source,
                        title=raw["summary"],
                        status=_STATUS_MAP.get(raw["state"], WorkItemStatus.open),
                        url=raw["self_url"],
                        updated_at=datetime.fromisoformat(raw["updated"]),
                    )
                )
            if not payload.get("has_more"):
                break
            page += 1
        return items
# app/adapters/ci.py
from datetime import datetime, timezone

import httpx

from app.adapters.base import UpstreamAdapter
from app.models import WorkItem, WorkItemSource, WorkItemStatus

_RESULT_MAP = {
    "running": WorkItemStatus.in_progress,
    "passed": WorkItemStatus.done,
    "failed": WorkItemStatus.failed,
}


class CIAdapter(UpstreamAdapter):
    source = WorkItemSource.ci

    def __init__(self, base_url: str, token: str):
        self.base_url = base_url
        self.token = token

    async def fetch(self, client: httpx.AsyncClient) -> list[WorkItem]:
        items: list[WorkItem] = []
        cursor: str | None = None
        while True:
            params = {"cursor": cursor} if cursor else {}
            response = await client.get(
                f"{self.base_url}/pipelines",
                params=params,
                headers={"Authorization": f"Bearer {self.token}"},
            )
            response.raise_for_status()
            payload = response.json()
            for raw in payload["pipelines"]:
                items.append(
                    WorkItem(
                        id=f"ci-{raw['id']}",
                        source=self.source,
                        title=raw["name"],
                        status=_RESULT_MAP.get(raw["result"], WorkItemStatus.in_progress),
                        url=raw["link"],
                        updated_at=datetime.fromtimestamp(
                            raw["finished_at"] / 1000, tz=timezone.utc
                        ),
                    )
                )
            cursor = payload.get("next_cursor")
            if not cursor:
                break
        return items
# app/aggregator.py
import asyncio

import httpx

from app.adapters.base import UpstreamAdapter
from app.models import DashboardResponse, UpstreamHealth, WorkItem


async def gather_dashboard(
    client: httpx.AsyncClient, adapters: list[UpstreamAdapter]
) -> DashboardResponse:
    results = await asyncio.gather(
        *(adapter.fetch(client) for adapter in adapters),
        return_exceptions=True,
    )

    items: list[WorkItem] = []
    health: list[UpstreamHealth] = []
    for adapter, result in zip(adapters, results):
        if isinstance(result, Exception):
            health.append(
                UpstreamHealth(source=adapter.source, available=False, error=str(result))
            )
            continue
        items.extend(result)
        health.append(UpstreamHealth(source=adapter.source, available=True))

    items.sort(key=lambda item: item.updated_at, reverse=True)
    return DashboardResponse(items=items, upstream_health=health)
# app/main.py
from contextlib import asynccontextmanager

import httpx
from fastapi import Depends, FastAPI, Request

from app.adapters.base import UpstreamAdapter
from app.adapters.ci import CIAdapter
from app.adapters.tickets import TicketingAdapter
from app.aggregator import gather_dashboard
from app.models import DashboardResponse


@asynccontextmanager
async def lifespan(app: FastAPI):
    async with httpx.AsyncClient(timeout=5.0) as client:
        app.state.http_client = client
        yield


app = FastAPI(lifespan=lifespan)


def get_http_client(request: Request) -> httpx.AsyncClient:
    return request.app.state.http_client


def get_adapters() -> list[UpstreamAdapter]:
    return [
        TicketingAdapter(
            base_url="https://tickets.example.com/api/v1", api_key="placeholder"
        ),
        CIAdapter(base_url="https://ci.example.com/api/v1", token="placeholder"),
    ]


@app.get("/dashboard", response_model=DashboardResponse)
async def dashboard(
    client: httpx.AsyncClient = Depends(get_http_client),
    adapters: list[UpstreamAdapter] = Depends(get_adapters),
) -> DashboardResponse:
    return await gather_dashboard(client, adapters)
# tests/test_dashboard.py
import httpx
import pytest

from app.main import app, get_http_client


def _mock_handler(request: httpx.Request) -> httpx.Response:
    if request.url.path == "/api/v1/issues":
        return httpx.Response(
            200,
            json={
                "results": [
                    {
                        "key": "OPS-104",
                        "summary": "Renew TLS certificate",
                        "state": "open",
                        "self_url": "https://tickets.example.com/OPS-104",
                        "updated": "2026-01-10T09:00:00+00:00",
                    }
                ],
                "has_more": False,
            },
        )
    if request.url.path == "/api/v1/pipelines":
        return httpx.Response(
            200,
            json={
                "pipelines": [
                    {
                        "id": 42,
                        "name": "deploy-staging",
                        "result": "failed",
                        "link": "https://ci.example.com/runs/42",
                        "finished_at": 1768124400000,
                    }
                ],
                "next_cursor": None,
            },
        )
    return httpx.Response(404)


async def _call_dashboard(handler) -> httpx.Response:
    app.dependency_overrides.clear()
    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as mock_upstream:
        app.dependency_overrides[get_http_client] = lambda: mock_upstream
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
            return await ac.get("/dashboard")


@pytest.mark.asyncio
async def test_dashboard_normalises_both_upstreams():
    response = await _call_dashboard(_mock_handler)

    assert response.status_code == 200
    body = response.json()
    assert {item["source"] for item in body["items"]} == {"ticketing", "ci"}
    assert {item["status"] for item in body["items"]} == {"open", "failed"}
    assert all(h["available"] for h in body["upstream_health"])


@pytest.mark.asyncio
async def test_dashboard_degrades_when_one_upstream_is_down():
    def flaky_handler(request: httpx.Request) -> httpx.Response:
        if request.url.path == "/api/v1/pipelines":
            return httpx.Response(503, json={"error_code": "unavailable"})
        return _mock_handler(request)

    response = await _call_dashboard(flaky_handler)

    assert response.status_code == 200
    body = response.json()
    assert [item["source"] for item in body["items"]] == ["ticketing"]
    health = {h["source"]: h["available"] for h in body["upstream_health"]}
    assert health == {"ticketing": True, "ci": False}

Run it:

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest -v

Both tests pass:

tests/test_dashboard.py::test_dashboard_normalises_both_upstreams PASSED
tests/test_dashboard.py::test_dashboard_degrades_when_one_upstream_is_down PASSED

Neither test opens a socket. The second is the interesting one: CI returns a 503, and the dashboard still returns 200 with the ticketing item present and CI marked unavailable — the decision the naive proxy at the top of this article had nowhere to put.

Conclusion

A backend-for-frontend earns its place once the frontend’s job stops being “render what the upstream sent” and becomes “render one coherent view built from upstreams that agree on nothing but the concept.” A few things about it generalise past this dashboard:

The schema is the product, not the plumbing. Deciding WorkItemStatus has exactly four values is made once, by someone who sees every upstream’s vocabulary at once — not wherever a frontend engineer next renders a badge.

Partial failure belongs in the response type, not a try/except an adapter author has to remember. UpstreamHealth turns “is everything working” from an implicit status code into a field a caller, and an alert, can check.

Caching lives where the multiplier lives. The aggregation endpoint sees total request volume, a better place to absorb load than duplicating rate-limit handling per adapter.

Adapters are commodity code because the contract is narrow. One method, one return type, tested against a crafted response rather than a live system — what makes a fifth backend a small change rather than a rewrite of the aggregator.