02-data-and-fhirwave: W1filled4 citations

FHIR Partitions and Multi-Tenancy

Audiences: developer, internal

FHIR Partitions and Multi-Tenancy

Each Dudoxx clinic gets its own isolated HAPI FHIR partition (integer ID), keyed off the clinic slug carried on every request as X-Clinic-ID — giving us "one HAPI server, many clinics" with hard data isolation at the JPA layer.

Business Purpose

Dudoxx HMS is sold as a multi-tenant SaaS where every clinic's clinical data (Patient, Appointment, Observation, …) MUST be cryptographically and operationally isolated from every other clinic. HAPI FHIR Partitions give us that isolation in ONE HAPI process — no fork-per-tenant — which means:

  • One Java JVM, one database, one ops surface for N clinics → low marginal cost per tenant, important for selling into mid-size practices that cannot absorb per-tenant infra fees.
  • A single HAPI version + schema upgrade ripples to every clinic atomically — no fleet drift, no per-tenant migration backlog.
  • Tenant onboarding is a row-insert (organization + partition row), not a deploy — the Super Admin portal can spin up a new clinic in seconds.

Audiences

  • Investor: Tenant isolation is a hard requirement for German clinic SaaS (DSGVO + KBV). HAPI Partitions let us achieve it without per-tenant cloud bills — supports the unit economics of an SMB-priced HMS.
  • Clinical buyer (doctor/nurse/receptionist): Your clinic's Patient list, Appointment book, and lab results are physically partitioned from every other clinic's data inside the FHIR server. A misrouted request returns nothing — it never returns another clinic's row.
  • Developer/partner: Every FHIR call MUST carry X-Clinic-ID: <clinic-slug>. The ClinicPartitionInterceptor resolves the slug → partition ID against the TenantRegistryService cache and pins the JPA query to RequestPartitionId.fromPartitionId(n). No slug = DEFAULT partition (used only for /metadata and IG installs).
  • Internal (ops/support): New clinics are provisioned through POST /super-admin/tenants in NestJS, which writes the organizations.fhirPartitionId row and calls POST /admin/tenants/refresh on HAPI to reload the cache. The cache also auto-refreshes every 60 s.

Architecture

diagram
┌────────────────────┐     X-Clinic-ID: ddx-hamburg-clinic     ┌─────────────────────────┐
│   ddx-api (NestJS) │ ──────────────────────────────────────► │   HAPI FHIR (internal)  │
│   port 6100        │     Authorization: Bearer ddx-…         │                         │
│                    │                                         │  ApiTokenAuthInterceptor│
│  FhirPartitionSvc  │                                         │           │             │
│  (UUID→slug cache) │                                         │           ▼             │
└────────────────────┘                                         │  ClinicPartitionIntercep│
                                                               │  (slug → partition ID)  │
                                                               │           │             │
                                                               │           ▼             │
                                                               │  TenantRegistryService  │
                                                               │  (60 s cache, reads     │
                                                               │   ddx_api_main.organiz.)│
                                                               │           │             │
                                                               │           ▼             │
                                                               │  RequestPartitionId     │
                                                               │  .fromPartitionId(N)    │
                                                               │           │             │
                                                               │           ▼             │
                                                               │  HAPI JPA → HFJ_RESOURCE│
                                                               │   WHERE PARTITION_ID=N  │
                                                               └─────────────────────────┘

Two PostgreSQL datasources, one HAPI process (host ports are host-qualified — resolve LIVE from context/ENVIRONMENT.md / ddx-fhir/CLAUDE.md; dev band is 6xxx, acc-dev is 17xxx — never hardcode):

DatasourceDBPurpose
Primary (spring.datasource)ddx_fhir_coreAll hfj_* FHIR tables incl. HFJ_PARTITION (Hibernate-owned, NEVER Prisma)
Tenant (spring.tenant-datasource)ddx_api_mainRead-only access to organizations for slug→partition mapping

The TenantRegistryService is the one bridge — it reads organizations (belonging to NestJS) over a second JDBC datasource and caches the mapping in memory. HAPI never WRITES to ddx_api_main.

Tech Stack & Choices

  • HAPI FHIR 8.10.0-1 / Java 21 / Spring Boot 3.5.x, R4 (overlay+vendor+git-subtree layout under ddx-fhir/overlay/ + ddx-fhir/vendor/, NOT a forked pom; the old 8.6.0-1 forked src/main/java/ca/uhn/... tree is retired).
  • Partitioning: HAPI's built-in RequestPartitionId + STORAGE_PARTITION_IDENTIFY_* pointcuts. No custom JPA sharding — partitions are a HAPI primitive.
  • Slug as routing key: clinics are addressed by slug (ddx-hamburg-clinic), not UUID. The slug is stable for the life of the clinic, URL-safe, and shows up in MinIO bucket names + observability logs — a single ID across the stack.
  • X-Clinic-ID header over path-prefix tenancy (e.g. /fhir/<tenant>/Patient): keeps URLs canonical and lets us swap tenants without rewriting clients.
  • 60 s cache refresh in TenantRegistryService (configurable via hapi.fhir.tenant.cache_refresh_seconds) — eventual consistency is acceptable because tenant onboarding force-refreshes via /admin/tenants/refresh.
  • Bearer token auth at the HAPI edge (ApiTokenAuthInterceptor) — HAPI is an INTERNAL service; only ddx-api ever calls it. Browser → HAPI is forbidden.
  • Rejected alternative — one HAPI per clinic: would have given iron-clad isolation but multiplied JVM, JDBC, IG-install, and ops cost per tenant. HAPI Partitions deliver the same row-level guarantee at the database layer.
  • Rejected alternative — organizationId filter in every query: forces every service to remember to scope, leaks data on the first missed filter. Partitioning is enforced at the JPA layer — a missed X-Clinic-ID returns the empty DEFAULT partition, not someone else's data.

Data Flow

Patient list request from a Hamburg doctor:

  1. Browser → Next.js /api/proxy/patients (validates JWT, attaches gateway headers including X-Clinic-ID: ddx-hamburg-clinic).
  2. Next.js → NestJS GET /patients on port 6100.
  3. NestJS service injects FHIR_CLIENT and calls client.search('Patient', …, 'ddx-hamburg-clinic').
  4. FhirClientService (or FhirPartitionService for UUID-form inputs) resolves the clinic slug, attaches X-Clinic-ID: ddx-hamburg-clinic + Bearer token, and fetch()es $FHIR_BASE/fhir/Patient (HAPI_FHIR_BASE_URL, resolved live).
  5. HAPI's ApiTokenAuthInterceptor validates the Bearer token.
  6. ClinicPartitionInterceptor reads X-Clinic-ID, calls TenantRegistryService.getPartitionId("ddx-hamburg-clinic") → 1, returns RequestPartitionId.fromPartitionId(1).
  7. HAPI JPA appends PARTITION_ID = 1 to the SQL → returns ONLY Hamburg rows.
  8. NestJS unwraps the FHIR Bundle, applies the response envelope ({data, meta}), and streams back to the browser.

If X-Clinic-ID is missing on a non-system request, HAPI returns the empty DEFAULT partition (PART_ID = 0) — never a cross-tenant leak.

Implicated Code

MANDATORY: ≥3 file:line citations.

  • ddx-fhir/overlay/src/main/resources/init-tenants.sql:38 — idempotent INSERT INTO organizations (slug, "fhirPartitionId", "isActive") seed for the default tenants. Partitions are NO LONGER seeded as static HFJ_PARTITION rows — they are allocated dynamically: partition_sequence (seeded at 1, so DEFAULT=0 stays reserved by HAPI) hands out the next id via TenantRegistryService.getNextPartitionId() when a tenant is provisioned.
  • ddx-fhir/overlay/src/main/java/com/dudoxx/fhir/r4/interceptor/ClinicPartitionInterceptor.java:68@Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ) entry point that resolves every read to a RequestPartitionId (a sibling :102 STORAGE_PARTITION_IDENTIFY_CREATE hook does the same for writes).
  • ddx-fhir/overlay/src/main/java/com/dudoxx/fhir/r4/interceptor/ClinicPartitionInterceptor.java:84extractClinicId(theRequestDetails); null slug falls back to RequestPartitionId.defaultPartition() (:73/:80/:88) rather than failing open.
  • ddx-fhir/overlay/src/main/java/com/dudoxx/fhir/r4/tenant/TenantRegistryService.java:36Map<String,Integer> clinicPartitionCache = new ConcurrentHashMap<>() — the in-memory slug → partition ID lookup populated from organizations.
  • ddx-fhir/overlay/src/main/java/com/dudoxx/fhir/r4/tenant/TenantRegistryService.java:64@Scheduled(fixedDelayString = "${hapi.fhir.tenant.cache_refresh_seconds:60}000") 60 s refresh — the lazy sync from NestJS.
  • ddx-fhir/overlay/src/main/java/com/dudoxx/fhir/r4/tenant/TenantRegistryService.java:79 — SQL SELECT slug, "fhirPartitionId" FROM organizations WHERE slug IS NOT NULL AND "fhirPartitionId" IS NOT NULL AND "isActive" = true.
  • ddx-fhir/overlay/src/main/java/com/dudoxx/fhir/r4/tenant/TenantAdminController.java — REST surface (POST /admin/tenants/refresh, register, GET /admin/tenants, DELETE /admin/tenants/{slug}, GET /admin/tenants/health).
  • ddx-api/src/platform/fhir/services/fhir-partition.service.ts:95resolveClinicSlug(clinicId) — NestJS-side UUID → slug translation with a 5-minute TTL cache; UUIDs are detected by regex and looked up against OrganizationService.
  • ddx-api/src/platform/fhir/services/fhir-partition.service.ts:112assignPartition(clinicId) returns { 'X-Clinic-ID': clinicSlug } — the canonical header builder.
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:33FhirClientService (canonical IFhirClient) takes clinicSlug as a required arg on every method and forwards it to GenericFhirService.
  • ddx-api/prisma-main/models/organization.prisma:26fhirPartitionId Int? @unique — the column the registry reads.

Operational Notes

  • Slug, not UUID, on the wire (REMINDERS pitfall): X-Clinic-ID: ddx-hamburg-clinic, never the Organization UUID. The Next.js BFF normalizes UUIDs to slugs; if a NestJS service has a UUID, it calls FhirPartitionService.resolveClinicSlug() first.
  • Cache lag = 60 s: a freshly-onboarded clinic is invisible to HAPI for up to 60 s unless POST /admin/tenants/refresh was called. The super-admin tenant-creation flow ALWAYS calls it (see CLAUDE.md "Tenant/Organization Creation"). Manual psql inserts into organizations do NOT.
  • DEFAULT (partition 0) is the safety floor: any request without an X-Clinic-ID (or with an unknown slug) reads partition 0, which holds IG resources + system metadata only. This is by design — fail empty, not leak.
  • Cross-partition queries are forbidden in app code. HAPI itself can serve cross-partition queries via _partition parameter, but every NestJS path attaches a single slug. ddx-fhir/scripts/preflight-cross-partition-refs.sql exists specifically to catch dangling cross-partition references before they cause runtime errors.
  • NEVER use Prisma against ddx_fhir_core: HAPI owns its 58 hfj_* tables; pointing Prisma migrate at the ddx_fhir_core datasource will DROP all FHIR data.
  • Partition IDs are integer; slugs are string. The mapping table is authoritative — never hardcode partition IDs in app code outside the init SQL.
  • HAPI is INTERNAL ONLY: the FHIR port is not exposed past the cluster. Browser → HAPI is forbidden; Next.js → HAPI is forbidden; only NestJS may call HAPI (Trusted Gateway pattern, see ddx-api/CLAUDE.md).
  • Validation commands (FHIR_BASE = HAPI_FHIR_BASE_URL, resolve LIVE — dev 6xxx / acc-dev 17xxx, never hardcode):
    bash
    FHIR_BASE="${HAPI_FHIR_BASE_URL%/fhir}"   # strip trailing /fhir
    
    # List all known partitions
    curl -H "Authorization: Bearer ddx-api-token-2024" \
         "$FHIR_BASE/admin/tenants"
    
    # Force cache refresh after super-admin tenant create
    curl -X POST -H "Authorization: Bearer ddx-api-token-2024" \
         "$FHIR_BASE/admin/tenants/refresh"
    
    # Verify isolation — Hamburg slug returns Hamburg rows only
    curl -H "Authorization: Bearer ddx-api-token-2024" \
         -H "X-Clinic-ID: ddx-hamburg-clinic" \
         "$FHIR_BASE/fhir/Patient"
    
  • FHIR ResourceFacade — the per-resource dispatch primitive that uses partitioning as the foundation for tenant-scoped Prisma/MinIO mirrors via the (resourceType, fhirId, clinicId) bridge.
  • Prisma 7 Schemas — the organizations.fhirPartitionId column lives in ddx_api_main and is the source of truth for the partition mapping.
  • High-Availability Strategy — HAPI is a singleton in the current topology; partitioning is what makes "one HAPI per cluster" tenable.