# Hc Bff Pattern

> Add or modify a BFF (Backend-For-Frontend) aggregation route in hc-portal. Use when the user asks to "add a BFF endpoint", "join data across services", "compose a view that needs patient + appointment + lab", "fan out to multiple services", "build a portal route", or anything about cross-service aggregation. Enforces the "BFF is the only joiner" rule and the canonical concurrent-fetch / token-forwarding patterns.

- Skill: `databricks-solutions/hc-bff-pattern` (Agent Skill)
- Install (CLI): `npx skillmds@latest add databricks-solutions/hc-bff-pattern`
- Raw SKILL.md: https://api.skillmd.com/api/skills/databricks-solutions/hc-bff-pattern/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: databricks-solutions (https://skillmd.com/u/databricks-solutions)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/databricks-solutions/hc-bff-pattern

---


# hc-bff-pattern

The BFF (`frontend/hc-portal/`) is the **only** place in this architecture where data from multiple services can be combined. Backend services never call each other. This skill is the source of truth for how to write a BFF route.

## When to use

- "Add a `/patient-summary/{id}` route that returns demographics + last 3 appointments + active meds"
- "Build the dashboard view that mixes lab + billing data"
- "Fan out to N services concurrently and merge the results"
- "Add a write route to the BFF" (rare — see [Write routes](#write-routes-rare-and-careful) below)

## When NOT to use

- The user wants to add a single-service route → it belongs in that service, not the BFF. Use `hc-obo-auth`.
- The user wants the BFF to *cache* data across requests → don't. Push the join down to UC if it's expensive.
- The user wants the BFF to call its *own* database → it doesn't have one. The BFF holds no state.

## The four BFF rules

1. **No persistent state.** No DB pool, no Redis, no shared cache. If you find yourself wanting one, stop and reconsider whether the data really belongs in a service.
2. **Concurrent fan-out.** Use `asyncio.gather` for independent calls. Sequential awaits are a code review reject.
3. **Per-request clients.** Construct service clients inside the route handler, not at module scope. Each request gets its own clients with its own bound user token.
4. **Forward both headers.** `Authorization: Bearer <token>` *and* `X-Forwarded-Access-Token`. The downstream service should be unable to tell whether the user called directly or via the BFF.

## Canonical aggregation route

Worked example: the `patient-summary` route from [`HEALTHCARE_DATA_MODEL.md`](../../../HEALTHCARE_DATA_MODEL.md#how-the-bff-composes-a-patient-summary-page).

```python
from __future__ import annotations
import asyncio
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

from ..auth import user_token  # same shape as the service-side auth.py
from ..clients import (
    PatientClient,
    ProviderClient,
    AppointmentClient,
    LabClient,
    PrescriptionClient,
    BillingClient,
)

router = APIRouter(tags=["aggregations"])


class PatientSummaryOut(BaseModel):
    patient: dict
    last_appointments: list[dict]
    active_prescriptions: list[dict]
    recent_lab_orders: list[dict]
    outstanding_invoices: list[dict]


@router.get(
    "/patient-summary/{patient_id}",
    response_model=PatientSummaryOut,
    operation_id="getPatientSummary",
)
async def patient_summary(
    patient_id: UUID,
    token: Annotated[str, Depends(user_token)],
):
    async with (
        PatientClient(token) as patient,
        AppointmentClient(token) as appointment,
        LabClient(token) as lab,
        PrescriptionClient(token) as rx,
        BillingClient(token) as billing,
    ):
        # Concurrent fan-out — none of these depends on another
        results = await asyncio.gather(
            patient.get(patient_id),
            appointment.list_for_patient(patient_id, limit=3, order="desc"),
            lab.list_orders_for_patient(patient_id, status="resulted", limit=5),
            rx.list_active_for_patient(patient_id),
            billing.list_outstanding_for_patient(patient_id),
            return_exceptions=True,
        )

    # Partial-failure handling: if the patient lookup itself fails, 404/5xx out.
    # If a peripheral lookup fails, return what we have with a `partial: true` flag.
    p, appts, labs, rxs, bills = results
    if isinstance(p, Exception):
        raise HTTPException(502, "patient-svc unavailable") from p

    def _safe(value, default):
        return default if isinstance(value, Exception) else value

    return PatientSummaryOut(
        patient=p.model_dump(),
        last_appointments=[a.model_dump() for a in _safe(appts, [])],
        active_prescriptions=[r.model_dump() for r in _safe(rxs, [])],
        recent_lab_orders=[l.model_dump() for l in _safe(labs, [])],
        outstanding_invoices=[b.model_dump() for b in _safe(bills, [])],
    )
```

Notes on the shape:

- **`return_exceptions=True`**: a single failing peripheral call doesn't take down the whole page. The patient-load *is* required (we fail closed).
- **No N+1 calls**: when you need provider names for the appointments, batch them — `provider.list(ids=[...])`, not a loop of `provider.get(id)`.
- **Pydantic in / Pydantic out**: clients return validated objects, the BFF re-serializes for the response. Type errors caught at the boundary.

## When to resolve names vs leave IDs

The `patient-summary` example returns appointments with raw `provider_id` UUIDs. Two options:

| Option | When to pick |
|---|---|
| Resolve names in the BFF (one extra batched call) | The frontend renders names "above the fold"; users perceive the latency of every call |
| Leave IDs, frontend fetches names lazily via Suspense | The list is long, name resolution is optional, or cardinality is high (e.g. 50 appointments) |

The reference defaults to **resolve in the BFF** for primary aggregation views (patient summary, appointment detail), and **leave IDs** for list/index views (the "all appointments" page).

## Write routes (rare and careful)

The BFF should mostly be read-only aggregation. When a write spans services, prefer:

1. **Don't.** Reshape the request so it goes to one service. Often this is achievable.
2. If you can't, write a **saga-style** route: call service A, then service B, with explicit compensation if B fails. Document this loudly.
3. Never wrap multiple service writes in a "transaction" — there are no distributed transactions here.

Concrete example: "book an appointment AND send a notification". Wrong shape — the BFF books, then enqueues a notification job (Lakeflow / job task), with no expectation of atomicity. The notification can fire eventually.

## Timeouts and resilience

- Default per-call timeout: `connect=2s, read=5s` (in `_BaseSvcClient`).
- A BFF route that fans out to N services should respond within `max(per-call) + overhead`, not `sum(per-call)`. Always concurrent.
- No retries in v1. A 503 from a service propagates as a 502 from the BFF (or as a partial response). Adding retries hides flakiness — make services boring instead.
- No circuit breakers in v1. Adding `pybreaker` is a justified follow-up if you observe a service that takes everyone down with it.

## How downstream URLs are resolved

The BFF doesn't read per-service URL env vars. `clients/_base.py` derives them at runtime from two platform-injected vars:

- `DATABRICKS_APP_URL` — the BFF's own URL, e.g. `https://hc-portal-dev-<workspace-id>.aws.databricksapps.com`.
- `DATABRICKS_WORKSPACE_ID` — the workspace this BFF is deployed in.

Sibling services share the same `<target>-<workspace_id>.<region>.databricksapps.com` tail and follow the `<svc>-<target>` naming pattern, so the BFF just strips its own `hc-portal` prefix off the host and prepends the per-service slug. No bundle `config.env`, no `<SVC>_SVC_URL` env vars, no `apps update` step. See `clients/_base.py:_own_app_suffix` for the canonical implementation.

This was deliberate: DAB has two relevant gaps — `${resources.apps.<key>.url}` does not resolve at bundle-deploy time (validate output shows `url=null`), and `bundle deploy` doesn't push `config.env` to the App spec without an extra `apps update` round-trip. Deriving from `DATABRICKS_APP_URL` sidesteps both.

## Local development

For non-Apps local dev, the BFF derives URLs the same way: set `DATABRICKS_APP_URL` to a fake `hc-portal-*-host` value that satisfies the prefix-strip — `respx` (or your local mocks) can intercept the resulting URLs. See `tests/conftest.py:_stub_svc_env` for a working stub. Then:

```bash
# Make the BFF accept the dev's CLI token as if it were forwarded
LOCAL_DEV_TOKEN_FROM_CLI=true
LOCAL_DEV_TOKEN=<paste from `databricks auth token -p hc-dev | jq -r .access_token`>
```

`apx dev start` from `frontend/hc-portal/` boots the React app + the BFF.

## Anti-patterns

| Pattern | Why it's wrong |
|---|---|
| Module-scope client (`patient_client = PatientClient(...)`) | Loses per-user token isolation; one global client shares one identity |
| Sequential awaits in a route (`a = await x(); b = await y()`) | Doubles the wall-clock time when nothing depends on the order |
| Caching response bodies in the BFF | Stale data reads as a bug; BFF is stateless on purpose |
| `try/except` swallowing service errors with empty default | Hides outages from the user; use `return_exceptions=True` + explicit `partial:true` flag |
| Joining data the frontend doesn't render | "Just in case" data inflates payloads and ties services together unnecessarily |

## Tests

`frontend/hc-portal/tests/integration/test_<route>.py` — runs against deployed preview apps in CI:

```python
@pytest.mark.integration
async def test_patient_summary_concurrent(httpx_client, user_token, sample_patient_id):
    import time
    t0 = time.monotonic()
    r = await httpx_client.get(
        f"/api/bff/patient-summary/{sample_patient_id}",
        headers={"X-Forwarded-Access-Token": user_token},
    )
    elapsed = time.monotonic() - t0
    assert r.status_code == 200
    # Concurrent fan-out: should be roughly the slowest single call, not their sum
    # Five 5xx calls in series would be ~25s; concurrent is < ~6s including overhead
    assert elapsed < 6.0, f"BFF took {elapsed:.1f}s — fan-out is probably sequential"
```

Plus a `test_partial_failure` that injects a 503 from one service via WireMock or a stub and asserts the response still 200s with `partial: true`.

## Verification

After adding a route:

- [ ] `apx dev check` passes (TS + Python types).
- [ ] OpenAPI regenerated; the React Suspense hook for the new route shows up in `ui/src/lib/api.ts`.
- [ ] Integration test exists and passes against the preview app.
- [ ] Per-call timeouts set on every client (the base class enforces this).
- [ ] No state added to the BFF process.
- [ ] No backend service was modified to call another backend service.

