FHIR ResourceFacade — Canonical Prisma↔FHIR↔MinIO Storage Primitive
Audiences: developer, internal
One declarative descriptor per FHIR resource type lets
ResourceFacade<R>routecreate/read/update/delete/searchacross HAPI FHIR, two Prisma databases, and MinIO — including a Tier-0 bridge row infhir_resource_linkthat pins every FHIR resource to a tenant-scoped Prisma key.
Authoritative deep-dive: see
ddx-documentation/97-fhir-resource-facade.mdfor 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
useFactoryprovider, not a new service + module + Prisma model. - Hard tenant guarantee at write time — every FHIR create runs the Tier-0
makeBaseLinkHooksafterCreate that upserts afhir_resource_linkrow keyed on(resourceType, fhirId, clinicId). Cross-tenant joins on FHIR data become cheap and safe. - Migration path off legacy
FhirService—FhirServicewas retired 2026-05-17;FhirResourceService+FhirSearchServiceremain in-tree as@deprecatedfor 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>+ aResourceDescriptor<R>. DirectFHIR_CLIENTinjection is allowed for transitional code, butnew FhirService()is forbidden —FhirServiceno longer exists, andFhirResourceService/FhirSearchServiceare@deprecated(CR-031 tracks deletion). - Internal (ops/support): All FHIR writes that go through the facade get a
fhir_resource_linkrow inddx_api_main.fhir_resource_link— that table is the cross-clinic index used by audit, partition routing, and (eventually) cross-system queries.
Architecture
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
useFactoryprovider (the facade is generic onR, so it cannot be a global provider — seedata-context.module.ts:26). - Tier-0 / Tier-1 hooks — descriptor
hooks(Tier-0) run first (makeBaseLinkHooksupserts the bridge row), thenadapter.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-agnostic —
MinioPortreceives a fully-qualified bucket + key; clinic-bucket resolution lives upstream ingetClinicBucket(slug, type). This is intentional — coupling RBAC into the port would force every port consumer to fabricate Attachment context. fhir_resource_linkbridge table (prisma-main/models/clinical.prisma) withUNIQUE (resourceType, fhirId, clinicId)— Prisma can join FHIR data by tenant without round-tripping HAPI.- Rejected — per-resource service (legacy
FhirServicemodel): 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 onR; one global instance cannot serve N resource types. Per-featureuseFactoryis the right grain.
Data Flow
Creating an Invoice (canonical financial example):
- Service injects
INVOICE_RESOURCE_FACADEtoken → aResourceFacade<InvoiceResource>configured withstorageBindings: [{store: 'fhir'}, {store: 'prisma:main', field: 'identifier', repository: invoiceSequenceRepo}]. - Call
facade.create(invoice, 'ddx-hamburg-clinic', orgId). - Facade picks primary binding (
fhir) and dispatches toFHIR_CLIENT.create(...). - HAPI's
ClinicPartitionInterceptorroutes the write to partition 1 (Hamburg) — see FHIR Partitions and Multi-Tenancy. - On success, Tier-0
makeBaseLinkHooks.afterCreateupsertsfhir_resource_link (resourceType='Invoice', fhirId=<HAPI id>, clinicId='ddx-hamburg-clinic', organizationId=<orgId>). - Tier-1 adapter hook runs (e.g.
invoiceNumberAllocatorHookbumps the gap-less sequence inprisma:main). - Facade returns a
FacadeResult<InvoiceResource>discriminated union — the service narrows withif (!result.ok) return …and readsresult.valuewithout casts.
Implicated Code
MANDATORY: ≥3 file:line citations.
Core engine
ddx-api/src/platform/fhir-resources/core/resource-facade.ts:206—class 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:43—StoreKindunion ('fhir' | 'prisma:main' | 'prisma:acc' | 'minio' | 'external').ddx-api/src/platform/fhir-resources/core/resource-descriptor.ts:121—ResourceDescriptor<R>interface signature.ddx-api/src/platform/fhir-resources/core/base-link-hooks.ts:39—makeBaseLinkHooks(resourceType, prismaMainClient)— Tier-0fhir_resource_linkupsert factory.ddx-api/src/platform/fhir-resources/core/minio-port.ts:1—MinioPortinterface (putObject,getObject,getPresignedPutUrl, …).ddx-api/src/platform/fhir-resources/core/minio-port.ts:36—MINIO_CLIENTDI symbol.
DI wiring
ddx-api/src/platform/infrastructure/data/data-context.module.ts:11—MINIO_CLIENTglobal 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-featureuseFactory).ddx-api/src/platform/infrastructure/fhir/fhir-client.service.ts:33—FhirClientService— theFHIR_CLIENTport the facade consumes forstore: 'fhir'bindings.
Active consumers (post-migration as of 2026-05-16)
ddx-api/src/financial/billing/billing.module.ts:91—new ResourceFacade<InvoiceResource>(...).ddx-api/src/financial/billing/billing.module.ts:109—new ResourceFacade<AccountResource>(...).ddx-api/src/financial/billing/billing.module.ts:125—new ResourceFacade<ChargeItemResource>(...).ddx-api/src/financial/billing/billing.module.ts:141—new ResourceFacade<ChargeItemDefinitionResource>(...).ddx-api/src/financial/insurance/insurance.module.ts:75—new ResourceFacade<ClaimResource>(...).ddx-api/src/financial/accounting/accounting.module.ts:129—new ResourceFacade<PaymentNoticeResource>(...).ddx-api/src/user-mgmt/user-management/user-management.module.ts:74—new ResourceFacade<PractitionerResource>(...).ddx-api/src/user-mgmt/user-management/user-management.module.ts:87—new 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:35—export class FhirResourceServicebody — kept inFhirInfrastructureModulefor transitional code only; do not introduce new callers.ddx-api/src/platform/fhir/services/fhir-search.service.ts:20—export class FhirSearchService— also@deprecatedper CR-031 and theddx-api/CLAUDE.md"FHIR Service Layers" table; new code usesFhirClientService.search()viaFHIR_CLIENTorResourceFacade.search().ddx-api/src/platform/fhir/fhir.module.ts:14— NF4.6 docstring confirming the legacy umbrellaFhirServiceclass +FHIR_SERVICEtoken were retired 2026-05-17; all consumers now depend onFHIR_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 deprecatedFhirResourceService/FhirSearchService— tracked under CR-031 (seecontext/REMINDERS.mdPermanent Reminders). new FhirService()is forbidden (ddx-api/CLAUDE.md Forbidden Patterns). The umbrellaFhirServiceclass was deleted; importing it will fail at type-check. Transitional code that cannot yet adopt the facade should injectFHIR_CLIENTdirectly — never recreateFhirService.- Hook errors are SWALLOWED (
resource-facade.ts:257). A failedfhir_resource_linkupsert does NOT roll back the FHIR write. If you need a compensating action, write it in the service that calledfacade.create— not in a hook. afterDeleteis a no-op stub by design inmakeBaseLinkHooks— 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 atplatform/storage/storage/utils.ts:126(getBucketTypeForAttachment) — NOT in the port. - Dual-arg call signature: every facade write takes
(resource, clinicSlug, organizationId). MissingorganizationIdwas the S2.1 follow-up bug in the smoke test — both args flow intoResourceMutationContextAND 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 inbinding.storecrashes 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
Related Topics
- FHIR Partitions and Multi-Tenancy — every
facade write is tenant-scoped via
X-Clinic-ID; the Tier-0 hook reuses the sameclinicIdto key the bridge row. - Prisma 7 Schemas — the
fhir_resource_linkbridge model lives inprisma-main/models/clinical.prisma, andprisma:main/prisma:accare the two PrismaStoreKinds the facade dispatches to. - Canonical pattern doc:
ddx-documentation/97-fhir-resource-facade.md.