02-data-and-fhirwave: W1filled28 citations

FHIR ResourceFacade — Canonical Prisma↔FHIR↔MinIO Storage Primitive

Audiences: developer, internal

FHIR ResourceFacade — Canonical Prisma↔FHIR↔MinIO Storage Primitive

One declarative descriptor per FHIR resource type lets ResourceFacade<R> route create/read/update/delete/search across HAPI FHIR, two Prisma databases, and MinIO — including a Tier-0 bridge row in fhir_resource_link that pins every FHIR resource to a tenant-scoped Prisma key.

Authoritative deep-dive: see ddx-documentation/97-fhir-resource-facade.md for the full pattern reference (StoreKind values, descriptor anatomy, recipes, patterns-to-follow / patterns-to-AVOID, file:line catalog). This KB page is the audience-facing summary; the canonical doc must NOT be paraphrased into the body.

Business Purpose

A typical FHIR resource in HMS is NOT just a row in HAPI — it's split across HAPI (canonical), Prisma (link rows, sequences, AR records, demographics mirror), MinIO (binary attachments), and sometimes external systems. Before ResourceFacade<R>, every feature module hand-wrote that orchestration — inconsistent error handling, inconsistent partition routing, inconsistent tenant scoping. ResourceFacade collapses all of that into one declarative descriptor per resource type:

  • One pattern, many resources — adding a NEW FHIR-backed resource is now ~50 lines of descriptor + a useFactory provider, not a new service + module + Prisma model.
  • Hard tenant guarantee at write time — every FHIR create runs the Tier-0 makeBaseLinkHooks afterCreate that upserts a fhir_resource_link row keyed on (resourceType, fhirId, clinicId). Cross-tenant joins on FHIR data become cheap and safe.
  • Migration path off legacy FhirServiceFhirService was retired 2026-05-17; FhirResourceService + FhirSearchService remain in-tree as @deprecated for the 47 non-financial consumers tracked under CR-031.

Audiences

  • Investor: This is the platform's "FHIR adapter for the rest of the business." It is what lets one engineer ship Invoice, Account, ChargeItem, PaymentNotice, Claim, Practitioner, and PractitionerRole on the same pattern in a quarter. Future resources cost descriptor-time, not service-time.
  • Clinical buyer: Invisible to clinical users — but it is why "the bill, the appointment, and the prescription are all consistent across tenants" is a guarantee we can actually make.
  • Developer/partner: New FHIR resources go through ResourceFacade<R> + a ResourceDescriptor<R>. Direct FHIR_CLIENT injection is allowed for transitional code, but new FhirService() is forbidden — FhirService no longer exists, and FhirResourceService / FhirSearchService are @deprecated (CR-031 tracks deletion).
  • Internal (ops/support): All FHIR writes that go through the facade get a fhir_resource_link row in ddx_api_main.fhir_resource_link — that table is the cross-clinic index used by audit, partition routing, and (eventually) cross-system queries.

Architecture

diagram
            ┌──────────────────────────────────────────────────┐
feature ──► │  ResourceFacade<R>                                │
module      │  resource-facade.ts:206                           │
(useFactory │                                                   │
 in module) │  1. boot: validate descriptor (Zod)               │
            │  2. beforeSearch hooks (search only)              │
            │  3. pick primary StorageBinding                   │
            │  4. dispatch by binding.store ─────────────────┐  │
            │  5. run afterCreate / afterUpdate / afterDelete│  │
            └────────────────────────────────────────────────┼──┘
                                                             │
       ┌─────────────┬─────────────┬─────────────┬───────────┴───────┐
       ▼             ▼             ▼             ▼                   ▼
    fhir         prisma:main   prisma:acc      minio              external
    IFhirClient  LocalRepo<R>  LocalRepo<R>    MinioPort           adapter port
    (FHIR_CLIENT)(@ddx/prisma  (@ddx/prisma    (MINIO_CLIENT)
                  -main)        -accounting)
       │
       ▼
    HAPI FHIR
    (port 18080,
     partitioned —
     see [fhir-partitions-tenancy])
       │
       ▼
    afterCreate hook
    (Tier-0, makeBaseLinkHooks)
    ────────────────────────────►  fhir_resource_link
                                   (resourceType, fhirId, clinicId)
                                   UNIQUE (resourceType, fhirId, clinicId)
                                   in ddx_api_main (Prisma main DB)

Five StoreKind values today (core/resource-descriptor.ts:43-51): fhir, prisma:main, prisma:acc, minio, external.

Tech Stack & Choices

  • TypeScript + NestJS 11, per-feature useFactory provider (the facade is generic on R, so it cannot be a global provider — see data-context.module.ts:26).
  • Tier-0 / Tier-1 hooks — descriptor hooks (Tier-0) run first (makeBaseLinkHooks upserts the bridge row), then adapter.hooks (Tier-1) for resource-specific side effects (invoice-number allocation, presigned-PUT URLs, …). Hook errors are SWALLOWED — observability is the caller's job; rollback belongs in the orchestrating service.
  • Boot-time Zod validation of the descriptor — typos in StoreKind, missing repositories, or malformed bindings crash the module on start, not on the first request.
  • MinIO port is RBAC-agnosticMinioPort receives a fully-qualified bucket + key; clinic-bucket resolution lives upstream in getClinicBucket(slug, type). This is intentional — coupling RBAC into the port would force every port consumer to fabricate Attachment context.
  • fhir_resource_link bridge table (prisma-main/models/clinical.prisma) with UNIQUE (resourceType, fhirId, clinicId) — Prisma can join FHIR data by tenant without round-tripping HAPI.
  • Rejected — per-resource service (legacy FhirService model): forced every new resource to be a fresh class with hand-rolled error handling, no shared lifecycle, drift across modules. Retired 2026-05-17.
  • Rejected — global ResourceFacade<R> provider: facade is generic on R; one global instance cannot serve N resource types. Per-feature useFactory is the right grain.

Data Flow

Creating an Invoice (canonical financial example):

  1. Service injects INVOICE_RESOURCE_FACADE token → a ResourceFacade<InvoiceResource> configured with storageBindings: [{store: 'fhir'}, {store: 'prisma:main', field: 'identifier', repository: invoiceSequenceRepo}].
  2. Call facade.create(invoice, 'ddx-hamburg-clinic', orgId).
  3. Facade picks primary binding (fhir) and dispatches to FHIR_CLIENT.create(...).
  4. HAPI's ClinicPartitionInterceptor routes the write to partition 1 (Hamburg) — see FHIR Partitions and Multi-Tenancy.
  5. On success, Tier-0 makeBaseLinkHooks.afterCreate upserts fhir_resource_link (resourceType='Invoice', fhirId=<HAPI id>, clinicId='ddx-hamburg-clinic', organizationId=<orgId>).
  6. Tier-1 adapter hook runs (e.g. invoiceNumberAllocatorHook bumps the gap-less sequence in prisma:main).
  7. Facade returns a FacadeResult<InvoiceResource> discriminated union — the service narrows with if (!result.ok) return … and reads result.value without casts.

Implicated Code

MANDATORY: ≥3 file:line citations.

Core engine

  • ddx-api/src/platform/fhir-resources/core/resource-facade.ts:206class ResourceFacade<R extends FhirResource> declaration.
  • ddx-api/src/platform/fhir-resources/core/resource-facade.ts:213 — boot-time Zod descriptor validation (fails fast on misconfigured bindings).
  • ddx-api/src/platform/fhir-resources/core/resource-facade.ts:249 — hook execution order (Tier-0 descriptor hooks → Tier-1 adapter hooks; errors swallowed and logged).
  • ddx-api/src/platform/fhir-resources/core/resource-descriptor.ts:43StoreKind union ('fhir' | 'prisma:main' | 'prisma:acc' | 'minio' | 'external').
  • ddx-api/src/platform/fhir-resources/core/resource-descriptor.ts:121ResourceDescriptor<R> interface signature.
  • ddx-api/src/platform/fhir-resources/core/base-link-hooks.ts:39makeBaseLinkHooks(resourceType, prismaMainClient) — Tier-0 fhir_resource_link upsert factory.
  • ddx-api/src/platform/fhir-resources/core/minio-port.ts:1MinioPort interface (putObject, getObject, getPresignedPutUrl, …).
  • ddx-api/src/platform/fhir-resources/core/minio-port.ts:36MINIO_CLIENT DI symbol.

DI wiring

  • ddx-api/src/platform/infrastructure/data/data-context.module.ts:11MINIO_CLIENT global registration (the only globally-scoped port the facade needs at module-instantiation time).
  • ddx-api/src/platform/infrastructure/data/data-context.module.ts:26 — comment "NOT exported as a global provider — it is generic" (rationale for per-feature useFactory).
  • ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:33FhirClientService — the FHIR_CLIENT port the facade consumes for store: 'fhir' bindings.

Active consumers (post-migration as of 2026-05-16)

  • ddx-api/src/financial/billing/billing.module.ts:91new ResourceFacade<InvoiceResource>(...).
  • ddx-api/src/financial/billing/billing.module.ts:109new ResourceFacade<AccountResource>(...).
  • ddx-api/src/financial/billing/billing.module.ts:125new ResourceFacade<ChargeItemResource>(...).
  • ddx-api/src/financial/billing/billing.module.ts:141new ResourceFacade<ChargeItemDefinitionResource>(...).
  • ddx-api/src/financial/insurance/insurance.module.ts:75new ResourceFacade<ClaimResource>(...).
  • ddx-api/src/financial/accounting/accounting.module.ts:129new ResourceFacade<PaymentNoticeResource>(...).
  • ddx-api/src/user-mgmt/user-management/user-management.module.ts:74new ResourceFacade<PractitionerResource>(...).
  • ddx-api/src/user-mgmt/user-management/user-management.module.ts:87new ResourceFacade<PractitionerRoleResource>(...).

Legacy / deprecated FHIR access

  • ddx-api/src/platform/fhir/services/fhir-resource.service.ts:22@deprecated Use FhirClientService (via FHIR_CLIENT token from DataContextModule) for all new code. CR-031 tracks the 47 non-financial consumers still on this class.
  • ddx-api/src/platform/fhir/services/fhir-resource.service.ts:35export class FhirResourceService body — kept in FhirInfrastructureModule for transitional code only; do not introduce new callers.
  • ddx-api/src/platform/fhir/services/fhir-search.service.ts:20export class FhirSearchService — also @deprecated per CR-031 and the ddx-api/CLAUDE.md "FHIR Service Layers" table; new code uses FhirClientService.search() via FHIR_CLIENT or ResourceFacade.search().
  • ddx-api/src/platform/fhir/fhir.module.ts:14 — NF4.6 docstring confirming the legacy umbrella FhirService class + FHIR_SERVICE token were retired 2026-05-17; all consumers now depend on FHIR_CLIENT.

Operational Notes

  • Migration status (2026-05-18): 7 financial resources have descriptors and run through ResourceFacade<R> — Invoice, Account, ChargeItem, ChargeItemDefinition, Claim, PaymentNotice, Practitioner/PractitionerRole (the last two via user-management). 47 non-financial consumers remain on the legacy deprecated FhirResourceService / FhirSearchService — tracked under CR-031 (see context/REMINDERS.md Permanent Reminders).
  • new FhirService() is forbidden (ddx-api/CLAUDE.md Forbidden Patterns). The umbrella FhirService class was deleted; importing it will fail at type-check. Transitional code that cannot yet adopt the facade should inject FHIR_CLIENT directly — never recreate FhirService.
  • Hook errors are SWALLOWED (resource-facade.ts:257). A failed fhir_resource_link upsert does NOT roll back the FHIR write. If you need a compensating action, write it in the service that called facade.create — not in a hook.
  • afterDelete is a no-op stub by design in makeBaseLinkHooks — the synthetic delete context lacks enough info for targeted removal. Adapters that need real cleanup override it.
  • MinIO bucket map: 7 canonical bucket types (documents | dicom | lab-results | attachments | recordings | exports | scratch). Bucket name resolution lives at platform/storage/storage/utils.ts:126 (getBucketTypeForAttachment) — NOT in the port.
  • Dual-arg call signature: every facade write takes (resource, clinicSlug, organizationId). Missing organizationId was the S2.1 follow-up bug in the smoke test — both args flow into ResourceMutationContext AND into the bridge-row upsert.
  • Boot-time validation gotcha: type-only descriptor fields (hooks, adapter, repository) are NOT Zod-validated — they're verified by NestJS DI. A typo in binding.store crashes at startup; a missing repository crashes at first request.
  • Validation command — full pattern reference for adding a new resource:
    bash
    # Read the canonical recipe
    cat ddx-documentation/97-fhir-resource-facade.md