Skip to main content

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.

설치로 이동

소스 정보

저장소
databricks-solutions/microbricks
최근 소스 활동
2026년 6월 15일 21:26
감지된 SKILL.md 언어
영어
스타
4
포크
3

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
hc-bff-pattern
description
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.
# 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.
GitHub에서 보기