02-data-and-fhirwave: W1filled17 citations

HAPI FHIR R4 Server — Clinical Data Store

Audiences: developer, clinical-buyer, internal

HAPI FHIR R4 Server — Clinical Data Store

HAPI FHIR 8.10.0-1 is the authoritative clinical data store for Dudoxx HMS — every Patient, Encounter, Observation, DiagnosticReport, and DocumentReference is persisted in HAPI, partitioned by clinic, and accessed through a typed NestJS client that routes all requests through the correct tenant partition.

Business Purpose

Clinical data is legally regulated healthcare information. In Germany (and across EU healthcare systems), clinical records must be stored in a format that supports interoperability (HL7 FHIR R4), can be shared with insurers and referring practitioners, and is audit-safe. HAPI FHIR 8.10.0-1 satisfies all three requirements out of the box.

Dudoxx HMS uses HAPI as the canonical store for all structured clinical data — not as a proxy or export target, but as the live system of record. Prisma (ddx_api_main) stores operational metadata (scheduling, user accounts, waiting room state), but clinical content (diagnoses, lab results, notes, prescriptions) lives in HAPI.

This design means:

  • Any FHIR-capable external system (insurance gateway, lab, specialist network) can read Dudoxx patient data without transformation.
  • Clinical data is never locked in a proprietary schema — the FHIR resource structure is the public API.
  • Partition isolation (one partition per clinic) gives strong multi-tenancy guarantees required by DSGVO.

Audiences

  • Investor: FHIR compliance is a hard requirement for integrations with German public health systems (KBV, GKV, ePA). HAPI R4 certification removes a major regulatory blocker for market entry and differentiates Dudoxx from non-interoperable EMR alternatives.
  • Clinical buyer (doctor/nurse/receptionist): Clinical staff never interact with HAPI directly. The HAPI server powers the patient chart, lab results, clinical notes, and visit history that appear in the doctor portal — with sub-second response times for individual reads and under 500ms for FHIR search queries.
  • Developer/partner: All clinical reads and writes go through the IFhirClient interface (token FHIR_CLIENT) injected into NestJS services. Direct HTTP calls to the HAPI base URL are not permitted from application code — they bypass partition routing. Use fhirClient.read(), fhirClient.search(), fhirClient.create(), fhirClient.update(), fhirClient.delete() instead.
  • Internal (ops/support): On dev (dudoxxmac) HAPI runs as a native mvn/Spring Boot JVM (overlay+vendor build, NOT Docker); on acc-dev it is a native host Java process. It uses a dedicated ddx_fhir_core PostgreSQL database. Tenant onboarding registers a new tenant via TenantAdminController (POST /admin/tenants/register), and TenantRegistryService reconciles slug→partition from the organizations table on a 60 s cache refresh.

Architecture

diagram
ddx-api (NestJS 11)
  └── FhirInfrastructureModule          (fhir.module.ts)
        ├── FhirClientService            (fhir-client.service.ts)
        │     ├── read<R>(type, id, slug)
        │     ├── search<R>(type, params, slug)
        │     ├── create<R>(type, body, slug)
        │     ├── update<R>(type, id, body, slug)
        │     ├── delete(type, id, slug)
        │     ├── transaction(bundle, slug)
        │     ├── executeBatch(bundle, slug)
        │     ├── everything(patientId, slug)   ← $everything operation
        │     └── patch(type, id, ops, slug)    ← JSON Patch
        └── FhirPartitionService         (fhir-partition.service.ts)
              ├── resolveClinicSlug(clinicId) → slug   (UUID → slug, TTL cache)
              └── assignPartition(clinicId) → { 'X-Clinic-ID': slug }

ddx-fhir (HAPI FHIR R4 8.10.0-1, overlay layout)
  ├── overlay/.../r4/interceptor/ClinicPartitionInterceptor.java  ← reads X-Clinic-ID header
  ├── overlay/.../r4/tenant/TenantRegistryService.java            ← slug → partition int map (60 s cache)
  ├── overlay/.../r4/tenant/TenantAdminController.java            ← POST /admin/tenants/{refresh,register}
  └── overlay/src/main/resources/init-tenants.sql                ← seeds organizations + partition_sequence

Every FhirClientService method sends the clinic slug verbatim as the X-Clinic-ID header (the slug IS the tenant key on the wire — no UUID/partition-int leaks to the HTTP layer). HAPI's ClinicPartitionInterceptor resolves that slug to an integer partition via TenantRegistryService and scopes all reads/writes to the matching partition, providing tenant isolation at the JPA layer.

See coding_context/ddx-hms-context.md §Platform for the full module map.

Tech Stack & Choices

LayerTechnologyRationale
FHIR serverHAPI FHIR R4 8.10.0-1 (Java 21 / Spring Boot 3.5.x)Mature, HL7-certified; partition support; Spring Boot ops endpoints
FHIR versionR4 (not R4B/R5)KBV ePA uses R4; broadest lab/insurance compatibility in DACH region
Storage backendPostgreSQL 18-alpine (dedicated database ddx_fhir_core)Separate container/datasource from ddx_api_main; HAPI stores resources as JSON blobs in the JPA HFJ_RESOURCE table
PartitioningHAPI built-in partitions (integer ID per clinic)Hard tenant isolation without per-tenant schema or JVM — one HAPI process for all clinics
NestJS clientFhirClientService — typed wrapper over HAPI RESTEnforces partition routing; surfaces FHIR 404/400 as NestJS NotFoundException/BadRequestException
InterfaceIFhirClient — DI token FHIR_CLIENTEnables mock injection in tests; decouples service layer from HAPI implementation detail
Batch/transactionexecuteBatch() + transaction() methodsAtomic multi-resource writes for seeder and migration use cases

Key design decision: Application code NEVER constructs HAPI URLs directly. The FhirClientService owns all URL assembly, partition header injection, and error mapping. Bypassing the client (direct axios/fetch to the HAPI base URL) breaks tenant isolation — this is enforced by linting rules.

Data Flow

Clinical data write (e.g., create lab result)

  1. POST /api/v1/lab-resultsLabResultsController.create() (lab-results.controller.ts:88) with clinic slug from X-Clinic-ID header.
  2. LabResultsService.create() builds a FhirDiagnosticReportResource payload and calls fhirClient.create<FhirDiagnosticReportResource>('DiagnosticReport', resource, clinicSlug).
  3. FhirClientService.create() (fhir-client.service.ts:60) delegates to GenericFhirService, which sets the X-Clinic-ID: <slug> header (generic-fhir.service.ts:509) and POSTs to $FHIR_BASE/fhir/DiagnosticReport (base from HAPI_FHIR_BASE_URL).
  4. HAPI ClinicPartitionInterceptor validates the partition header, routes the write to the correct tenant partition, and returns the created resource with a server-assigned numeric ID.
  5. FhirClientService maps HAPI HTTP errors (404, 400, 422) to NestJS exceptions and returns the typed resource.

Tenant onboarding

  1. Super Admin creates a new organization via the Super Admin portal (POST /super-admin/tenants in NestJS — writes organizations.fhirPartitionId allocated from partition_sequence).
  2. NestJS calls POST /admin/tenants/refresh (or /admin/tenants/register) on HAPI's TenantAdminController to reload the slug→partition cache.
  3. TenantRegistryService reads the organizations table and caches the slug→partition-ID map in memory.
  4. init-tenants.sql seeds the organizations/partition_sequence/global_config tables idempotently; the 60 s @Scheduled refresh reconciles any organizations added while the cache was stale.

$everything operation

FhirClientService.everything(patientId, clinicSlug) (fhir-client.service.ts:150) issues a FHIR GET /Patient/{id}/$everything call, returning all resources linked to a patient (Encounters, Observations, Conditions, MedicationRequests, etc.) as a FHIR Bundle. Used by the patient export and medico-legal summary flows.

Implicated Code

  • ddx-api/src/platform/fhir/fhir.module.ts:41FhirInfrastructureModule — binds the FHIR_CLIENT token to FhirClientService, exports it (+ supporting services)
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:33FhirClientService — typed HAPI REST client; delegates CRUD/search/transaction to GenericFhirService, implements $everything/patch/ping directly
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:60create() — delegates to GenericFhirService.create (X-Clinic-ID header)
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:85search() — FHIR search with query params + slug routing
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:102executeBatch() — batch/transaction bundle execution (used by seeder)
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:150everything()$everything FHIR operation for full patient record export
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:203patch() — FHIR JSON Patch (RFC 6902, application/json-patch+json)
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:289transaction() — FHIR transaction bundle (all-or-nothing multi-resource write)
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:375ping() — health check against HAPI /metadata endpoint
  • ddx-api/src/platform/core/interfaces/services/fhir-client.interface.ts:80IFhirClient interface (FHIR_CLIENT symbol at :18) — DI contract; enables test mocks
  • ddx-api/src/platform/fhir/services/generic-fhir.service.ts:509buildHeaders() sets X-Clinic-ID: <slug> on every request
  • ddx-api/src/platform/fhir/services/fhir-partition.service.ts:95resolveClinicSlug() — UUID → slug resolution (TTL cache); assignPartition():112{ 'X-Clinic-ID': slug }
  • ddx-fhir/overlay/src/main/resources/init-tenants.sql:38 — seeds organizations + partition_sequence (partition 0 = DEFAULT/system reserved by HAPI, ≥1 = clinics)

Operational Notes

  • Direct HTTP to HAPI is forbidden: Any code that calls $FHIR_BASE/fhir/... directly bypasses partition routing and writes to the default (partition 0) tenant. This is a silent data-leak bug. All access must go through FHIR_CLIENT token.
  • HAPI health check: GET $FHIR_BASE/fhir/metadata returns a CapabilityStatement. FhirClientService.ping() (fhir-client.service.ts:375) wraps this for use in health checks. Base URL is HAPI_FHIR_BASE_URL — resolve LIVE (dev 6xxx / acc-dev 17xxx), never hardcode.
  • Partition 0 is the system partition: Resources written to partition 0 are visible to all tenants. Never write patient data to partition 0. The ClinicPartitionInterceptor blocks writes without an explicit X-Clinic-ID header to prevent accidental cross-tenant writes.
  • FHIR numeric IDs vs UUIDs: HAPI assigns numeric IDs (e.g., "4910") to resources. Domain services use UUIDs as the stable API surface and resolve to FHIR numeric IDs via fhir_resource_link rows or PatientIdResolverService. Passing a UUID to a raw HAPI endpoint returns 404.
  • Search performance: HAPI stores indexes in HFJ_SPIDX_* tables. Unindexed search params trigger full-table scans. LOINC-coded Observations and DocumentReference.category are indexed. If new search params are needed, add SearchParamDefinition annotations to the HAPI starter config.
  • Version: HAPI FHIR 8.10.0-1 (Java 21 / Spring Boot 3.5.x), overlay+vendor+git-subtree layout (ddx-fhir/pom.xml is a thin aggregator → vendor/hapi-fhir-jpaserver-starter; DDX customizations under overlay/). Upstream bumps via ddx-fhir/scripts/upstream-pull.sh <tag>. Breaking changes in the HAPI JPA layer require migration of HFJ_* tables — test in dev before updating.