FHIR Partitions and Multi-Tenancy
Audiences: developer, internal
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>. TheClinicPartitionInterceptorresolves the slug → partition ID against theTenantRegistryServicecache and pins the JPA query toRequestPartitionId.fromPartitionId(n). No slug =DEFAULTpartition (used only for/metadataand IG installs). - Internal (ops/support): New clinics are provisioned through
POST /super-admin/tenantsin NestJS, which writes theorganizations.fhirPartitionIdrow and callsPOST /admin/tenants/refreshon HAPI to reload the cache. The cache also auto-refreshes every 60 s.
Architecture
┌────────────────────┐ 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):
| Datasource | DB | Purpose |
|---|---|---|
Primary (spring.datasource) | ddx_fhir_core | All hfj_* FHIR tables incl. HFJ_PARTITION (Hibernate-owned, NEVER Prisma) |
Tenant (spring.tenant-datasource) | ddx_api_main | Read-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 forkedsrc/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-IDheader 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 viahapi.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; onlyddx-apiever 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 —
organizationIdfilter 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 missedX-Clinic-IDreturns the emptyDEFAULTpartition, not someone else's data.
Data Flow
Patient list request from a Hamburg doctor:
- Browser → Next.js
/api/proxy/patients(validates JWT, attaches gateway headers includingX-Clinic-ID: ddx-hamburg-clinic). - Next.js → NestJS
GET /patientson port 6100. - NestJS service injects
FHIR_CLIENTand callsclient.search('Patient', …, 'ddx-hamburg-clinic'). FhirClientService(orFhirPartitionServicefor UUID-form inputs) resolves the clinic slug, attachesX-Clinic-ID: ddx-hamburg-clinic+ Bearer token, andfetch()es$FHIR_BASE/fhir/Patient(HAPI_FHIR_BASE_URL, resolved live).- HAPI's
ApiTokenAuthInterceptorvalidates the Bearer token. ClinicPartitionInterceptorreadsX-Clinic-ID, callsTenantRegistryService.getPartitionId("ddx-hamburg-clinic") → 1, returnsRequestPartitionId.fromPartitionId(1).- HAPI JPA appends
PARTITION_ID = 1to the SQL → returns ONLY Hamburg rows. - 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— idempotentINSERT INTO organizations (slug, "fhirPartitionId", "isActive")seed for the default tenants. Partitions are NO LONGER seeded as staticHFJ_PARTITIONrows — they are allocated dynamically:partition_sequence(seeded at 1, so DEFAULT=0 stays reserved by HAPI) hands out the next id viaTenantRegistryService.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 aRequestPartitionId(a sibling:102STORAGE_PARTITION_IDENTIFY_CREATEhook does the same for writes).ddx-fhir/overlay/src/main/java/com/dudoxx/fhir/r4/interceptor/ClinicPartitionInterceptor.java:84—extractClinicId(theRequestDetails);nullslug falls back toRequestPartitionId.defaultPartition()(:73/:80/:88) rather than failing open.ddx-fhir/overlay/src/main/java/com/dudoxx/fhir/r4/tenant/TenantRegistryService.java:36—Map<String,Integer> clinicPartitionCache = new ConcurrentHashMap<>()— the in-memory slug → partition ID lookup populated fromorganizations.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— SQLSELECT 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:95—resolveClinicSlug(clinicId)— NestJS-side UUID → slug translation with a 5-minute TTL cache; UUIDs are detected by regex and looked up againstOrganizationService.ddx-api/src/platform/fhir/services/fhir-partition.service.ts:112—assignPartition(clinicId)returns{ 'X-Clinic-ID': clinicSlug }— the canonical header builder.ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:33—FhirClientService(canonical IFhirClient) takesclinicSlugas a required arg on every method and forwards it toGenericFhirService.ddx-api/prisma-main/models/organization.prisma:26—fhirPartitionId 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 callsFhirPartitionService.resolveClinicSlug()first. - Cache lag = 60 s: a freshly-onboarded clinic is invisible to HAPI for
up to 60 s unless
POST /admin/tenants/refreshwas called. The super-admin tenant-creation flow ALWAYS calls it (see CLAUDE.md "Tenant/Organization Creation"). Manualpsqlinserts intoorganizationsdo NOT. DEFAULT(partition 0) is the safety floor: any request without anX-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
_partitionparameter, but every NestJS path attaches a single slug.ddx-fhir/scripts/preflight-cross-partition-refs.sqlexists specifically to catch dangling cross-partition references before they cause runtime errors. - NEVER use Prisma against
ddx_fhir_core: HAPI owns its 58hfj_*tables; pointing Prisma migrate at theddx_fhir_coredatasource 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):bashFHIR_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"
Related Topics
- 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.fhirPartitionIdcolumn lives inddx_api_mainand 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.