Aggregate Storage Pattern — MinIO + Prisma + FHIR via ResourceFacade
Audiences: developer, internal
One logical "resource" in HMS is almost never one row in one database — it is a triple: canonical FHIR record on HAPI (HL7 interop), tenant-scoped Prisma rows (relational query + audit), and MinIO blobs (binary durability). The aggregate-storage pattern is how those three engines are coordinated behind a single descriptor, with brutally honest consistency guarantees: best-effort post-FHIR fan-out, NOT two-phase commit.
Canonical primitive page: FHIR ResourceFacade is the API surface and dispatch engine. This page does NOT restate the facade's API — it answers the aggregate-storage question: why three engines, how they fail, and what readers can rely on.
Business Purpose
A naive observer asks: "why three storage engines for an invoice or a clinical document, when one Postgres table would do?" The honest answer is that each engine solves a problem the other two can't:
- FHIR (HAPI on
ddx_fhir_core, port 18080) is the canonical clinical record in HL7 R4 shape. It is what external systems (EHRs, insurers, regulators, the DDX-RAG layer) speak. It is partitioned by clinic so cross-tenant queries are physically impossible by default — see FHIR Partitions and Multi-Tenancy. HAPI owns versioning, history, conditional updates, and Bundle semantics. We do not reinvent any of that. - Prisma (
ddx_api_main+ddx_api_acc) is the relational query surface. Tenant-scoped joins, audit trails, sequence allocation (gap-less invoice numbering), AR ledgers, and thefhir_resource_linkbridge table all live here. FHIR's search API cannot do a 5-table join with tenant scoping in a single request; Postgres can. - MinIO (S3-compatible, port 15000) holds the bytes. FHIR
Binaryresources max out HAPI well before clinical reality (DICOM studies, multi-megabyte PDFs, audio recordings, video). The FHIR record stores metadata + aurlpointer; the bytes go in a clinic-scoped bucket.
The aggregate-storage pattern says: a "Document" or "Invoice" or
"Recording" is not a row, it is the consistent view of all three engines
keyed on a single FHIR id. ResourceFacade<R> is the write-path that
keeps the keys aligned. Readers reconstitute the aggregate on demand —
HMS does NOT cache a fourth copy of the truth.
Audiences
- Investor: This is the architectural answer to the question "how do you ship a healthcare platform that is simultaneously HL7-compliant, queryable, and capable of handling DICOM studies + voice recordings?" Without aggregate storage, every feature module reinvents the HAPI+Postgres+S3 dance — drift across modules became the #1 source of cross-tenant leaks and dropped attachments in 2025. ResourceFacade collapses the dance to one descriptor.
- Clinical buyer: Invisible. The bill, the X-ray, and the prescription are all consistent because one engine owns the write order, and one bridge row guarantees the tenant scope. Failures degrade in observable ways (the bytes are missing, or the link row is missing) — they do not fabricate phantom data.
- Developer/partner: A new aggregate-storage resource is one
descriptor + a
useFactoryprovider. DirectFHIR_CLIENTor directStorageServiceaccess is allowed for transitional code, but new resources should go throughResourceFacade<R>so the bridge row + hook lifecycle land for free. See FHIR ResourceFacade for the API. - Internal (ops/support): Every facade-mediated FHIR write produces a
fhir_resource_linkrow inddx_api_mainkeyed(resourceType, fhirId, clinicId). If a support engineer can find the link row, the FHIR resource is canonical; if the link row is missing, the write either bypassed the facade (legacyFhirResourceServiceconsumer) or the Tier-0 hook swallowed a Prisma error — check theBaseLinkHookslog lines.
Architecture
One logical "resource" = aggregate of 3 engines.
Three engines, ONE write order: FHIR first → Prisma bridge row → Tier-1 side effects. Reads inverse-walk the same chain on demand — HMS never maintains a precomputed materialized view of the aggregate.
Tech Stack & Choices
- TypeScript + NestJS 11 + Prisma 7 + HAPI FHIR 8.4.0 + MinIO/S3
(
@aws-sdk/client-s3is the actual client; theminionpm package is retained for legacy code paths only — seeddx-api/CLAUDE.md"MinIO / File Storage"). - One descriptor per resource type declares
storageBindings[]. TheStoreKindunion covers the five engines today (fhir,prisma:main,prisma:acc,minio,external) — defined atcore/resource-descriptor.ts:43-51. - MinIO bucket types are a closed 7-enum
(
documents | dicom | lab-results | attachments | recordings | exports | scratch) validated by Zod at descriptor boot — seecore/resource-descriptor.ts:79-89. The port itself is bucket-naming agnostic; clinic-bucket resolution lives atplatform/storage/storage/utils.ts:14(getClinicBucket) and:127(getBucketTypeForAttachment). - Honest non-choice — no 2PC, no XA, no saga middleware. The facade
is best-effort with FHIR-first ordering. We considered:
- True two-phase commit across HAPI+Postgres+MinIO — rejected: HAPI does not expose a prepare/commit handshake, and even if it did, the operational cost (long-held locks, partial-failure forensics) is higher than the cost of compensating in the service layer.
- Outbox + transactional log → async reconciler — viable for future growth, but premature today. The aggregate is shallow: at most one FHIR resource + one bridge row + one optional blob per call.
- What we actually have: FHIR succeeds OR the facade returns
FacadeResult.ok=falseand nothing else runs. After FHIR succeeds, Tier-0 and Tier-1 hooks fire with swallowed errors —resource-facade.ts:257-263. A failed bridge-row upsert leaves the FHIR record orphan-but-canonical; observability lives in theBaseLinkHookslogger (base-link-hooks.ts:108-114). The service that orchestrated the facade is responsible for any compensating action. - Bridge table as the consistency primitive:
fhir_resource_link(prisma-main/models/clinical.prisma:429) carries(resourceType, fhirId, clinicId, organizationId)with a UNIQUE index on the first three columns. That is the only canonical mapping from FHIR id → tenant. Everything else (audit joins, partition routing short-circuits, cross-system queries) joins through it.
Data Flow
Write path (an InvoicePdf-style aggregate)
- Service injects
INVOICE_RESOURCE_FACADEtoken → aResourceFacade<Invoice>configured with three bindings:{store:'fhir'}(canonical),{store:'prisma:main', repository: invoiceSequenceRepo}(gap-less number allocator), and (optionally) a{store:'minio', bucket:'documents'}field-binding for the rendered PDF. - Caller invokes
facade.create(invoice, 'ddx-hamburg-clinic', orgId). - Primary binding is
fhir→IFhirClient.create(...)writes to HAPI withX-Clinic-ID: ddx-hamburg-clinic. HAPI'sClinicPartitionInterceptorroutes the write to partition 1 (Hamburg). HAPI assigns the FHIR id. - If FHIR fails, the facade returns
{ok:false, error}— Tier-0 and Tier-1 NEVER run. The state is consistent (nothing was written). - If FHIR succeeds, Tier-0
makeBaseLinkHooks.afterCreateupsertsfhir_resource_link (resourceType='Invoice', fhirId=<HAPI>, clinicId='ddx-hamburg-clinic', organizationId=<orgId>). Failure is logged and swallowed — the FHIR record is now canonical but cross-clinic indexing is missing. This is acceptable because (a) the FHIR record itself carries the partition tag, and (b) the link row can be reconciled by a sweeper. - Tier-1 adapter hooks fire (invoice-number allocator bumps
ddx_api_main; MinIO presigned-PUT URL minted for the PDF byte upload). Same swallow policy. - The facade returns
FacadeResult<Invoice>with the persisted resource. Caller narrowsif (!result.ok) …— no casts onresult.value(see FHIR ResourceFacade §"Patterns to follow").
Read path
There is no read-aggregator today. Each consumer fetches what it needs:
- A read by FHIR id goes to
IFhirClient.read(...)→ HAPI partition lookup. If the caller has the FHIR id, the bridge row is not strictly required. - A read by Prisma-side criteria (e.g. "all invoices for org X paid in
May") starts in Prisma (
fhir_resource_linkjoined against the feature's own tables) → returns FHIR ids → fan-out to HAPI for the canonical resource body. This is two round trips but bounded. - MinIO bytes are fetched via presigned-GET URLs minted by
MinioPort.presignedGetUrl(bucket, key)(core/minio-port.ts:124-128, default 900s expiry) — the FHIR resource carries theurlpointer; HMS does not stream bytes through the API.
Consistency guarantees readers can rely on
- FHIR record exists ⇒ the resource is canonical. Period. The
bridge row may be missing (rare, sweepable); the blob may be missing
(the FHIR
urlwill dangle); but FHIR is the source of truth for metadata. - FHIR record AND bridge row both exist ⇒ tenant scope is provably correct. Cross-tenant joins through the bridge row are safe.
- Blob exists at the FHIR-declared URL ⇒ no separate guarantee;
this is a presigned-URL race in principle but in practice the upload
completes before the FHIR record is persisted (Tier-1 PUT-URL adapters
return the URL during
afterCreate, the caller uploads, then a follow-up patches the FHIRurl— the canonical pattern is PUT-then-record, not record-then-PUT). - Read-your-write is honored within FHIR (HAPI strong consistency
per-partition). It is NOT honored across all three engines without
reading them in order: a service that just called
facade.createand immediately joins throughfhir_resource_linkmay race the Tier-0 hook if it dispatches on the same tick. The pragmatic fix is to use theFacadeResult.valuereturned fromcreate, not to re-query.
Implicated Code
MANDATORY: ≥3 file:line citations. Citations span the three engines.
Facade dispatch (FHIR primary, then fan-out)
ddx-api/src/platform/fhir-resources/core/resource-facade.ts:100-114— primary-binding selection rule (first non-FHIR whole-resource binding wins, else FHIR). Establishes "FHIR-first" as the default.ddx-api/src/platform/fhir-resources/core/resource-facade.ts:288-332—create()implementation: dispatch to primary store, thenrunMutationHook('afterCreate', …). FHIR-first ordering is hard-coded in this sequence.ddx-api/src/platform/fhir-resources/core/resource-facade.ts:249-265—runMutationHook()runs descriptor hooks (Tier-0) THEN adapter hooks (Tier-1) with swallowed errors. This is the "best-effort fan-out" primitive — no rollback, no retry, only logs.ddx-api/src/platform/fhir-resources/core/resource-facade.ts:60-68—FacadeResult<T>discriminated union (ok:truecarriesvalue,ok:falsecarrieserror). Only the primary-op error path producesok:false; hook failures NEVER do.ddx-api/src/platform/fhir-resources/core/resource-descriptor.ts:43-51—StoreKindSchema— the five engines aggregate storage can target.ddx-api/src/platform/fhir-resources/core/resource-descriptor.ts:79-89— closed 7-enum MinIObucketvalidator (documents | dicom | lab-results | attachments | recordings | exports | scratch).
Tier-0 bridge row (the consistency primitive)
ddx-api/src/platform/fhir-resources/core/base-link-hooks.ts:39-115—makeBaseLinkHooks(resourceType, prismaMainClient).afterCreateupsertsfhir_resource_linkon the(resourceType, fhirId, clinicId)unique index.ddx-api/src/platform/fhir-resources/core/base-link-hooks.ts:108-114— the swallow: link-row upsert failures are caught, logged viaLogger('BaseLinkHooks'), never re-thrown. "A link-row failure must not fail the FHIR write."ddx-api/src/platform/fhir-resources/core/base-link-hooks.ts:117-131—afterDeleteis a no-op stub by design. Synthetic delete contexts lack enough info for targeted row removal; Tier-1 adapters must override if they need real cleanup.ddx-api/prisma-main/models/clinical.prisma:429—model FhirResourceLinkdefinition. Lives inprisma-main/models/clinical.prisma(multi-file schema layout) — NOT in any feature module.ddx-api/prisma-main/migrations/20260516_add_fhir_resource_link/migration.sql— migration that created the table + UNIQUE(resourceType, fhirId, clinicId)index.
MinIO port + tenant-aware bucket resolution
ddx-api/src/platform/fhir-resources/core/minio-port.ts:36—MINIO_CLIENT = Symbol('MINIO_CLIENT')DI token.ddx-api/src/platform/fhir-resources/core/minio-port.ts:101-140—MinioPortinterface:presignedPutUrl/presignedGetUrl/headObject/deleteObject. Intentionally tenant-AGNOSTIC — receives fully-qualifiedbucket+key, never resolves clinic slug.ddx-api/src/platform/fhir-resources/core/minio-port.ts:14-20— rationale doc-comment for keeping RBAC/Attachment concerns OUT of the port ("those concerns remain inStorageService").ddx-api/src/platform/storage/storage/utils.ts:14—getClinicBucket(slug, type)resolves{slug}-{type}(orplatform-{type}when slug === 'default').ddx-api/src/platform/storage/storage/utils.ts:127—getBucketTypeForAttachment(att)maps attachment kind → bucket type (DIAGNOSTIC_REPORT→ reports,MEDICAL_RECORD→ documents, …).
Operational Notes
- CR-031 migration status (2026-05-18): 7 financial resources run
through the aggregate-storage pattern via
ResourceFacade<R>— Invoice, Account, ChargeItem, ChargeItemDefinition, Claim, PaymentNotice, plus Practitioner/PractitionerRole (user-management). 47 non-financial consumers still on the legacyFhirResourceService/FhirSearchServiceclasses — those callers write directly to HAPI + MinIO + Prisma with hand-rolled orchestration and DO NOT produce bridge rows. CR-031 tracks the migration. Until it lands,fhir_resource_linkis not a full cross-system index — only the migrated resources are guaranteed to appear there. new FhirService()is forbidden (ddx-api/CLAUDE.md Forbidden Patterns). The umbrellaFhirServiceclass was retired 2026-05-17. Transitional code that cannot adopt the facade must injectFHIR_CLIENTdirectly — and accept that it produces NO bridge row.- Aggregate-storage failure modes you will actually see:
- FHIR write fails →
FacadeResult.ok=false. Nothing else runs. State is clean. Caller surfaces the error. - FHIR write succeeds, Tier-0 link upsert fails → FHIR record is
canonical, bridge row missing. Cross-clinic queries by Prisma key
will not find the resource. Log line:
makeBaseLinkHooks(X): link-row upsert failed — <reason>. Reconcile by re-running the upsert with the FHIR id from the log. - FHIR write succeeds, Tier-1 MinIO presigned-PUT URL minted, client
fails to PUT bytes → FHIR record carries a
urlpointing at a nonexistent object. The FHIR resource is still canonical; the blob is missing. Detect viaMinioPort.headObjectsweep againstfhir_resource_link-discovered URLs. - Tier-1 adapter throws (e.g. invoice-number allocator collision) → swallowed. Invoice will lack a gap-less number. The facade does not compensate; the orchestrating service must.
- FHIR write fails →
- There is no atomic delete across engines.
afterDeleteis a no-op stub. A "delete invoice" call removes the FHIR resource and orphans the bridge row + blob. Adapters that need real cleanup must overrideafterDeletewith thefhirIdcaptured atbeforeDeletetime — pattern documented atcore/base-link-hooks.ts:117-131. - MinIO bucket map (7 canonical types):
documents | dicom | lab-results | attachments | recordings | exports | scratch. Naming format:{clinic-slug}-{type}(orplatform-{type}for shared/default-slug resources). The same enum is the Zod-validatedbucketdiscriminator on the StorageBinding (resource-descriptor.ts:79-89). - MinioPort is intentionally a thin port, NOT a Service. The full
RBAC + Attachment-aware code path is
StorageServiceinplatform/storage/storage.service.ts; the port is what the facade dispatches against. Coupling RBAC into the port would force every consumer to fabricate Attachment context — see plan learning atminio-port.ts:14-27. - Reconciliation tools: There is no formal reconciler today.
Operational practice when a defect surfaces:
SELECT * FROM fhir_resource_link WHERE resourceType=... AND fhirId=...to confirm bridge presence.GET /fhir/<Type>/<fhirId>withX-Clinic-IDto confirm canonical FHIR record.MinioPort.headObject(bucket, key)(ormc statagainst the MinIO console at port 15000) to confirm blob presence. If only one of the three is missing, the recovery is "re-run the missing step", not "roll back the other two".
Related Topics
- FHIR ResourceFacade — the canonical primitive page; full API surface, descriptor anatomy, DI wiring, patterns to follow / patterns to avoid. Read this first if you are authoring a new aggregate-storage resource.
- FHIR Partitions and Multi-Tenancy —
the FHIR side of the aggregate. Every facade write is tenant-scoped
via
X-Clinic-ID, which is the sameclinicIdthat keys the Tier-0 bridge row. - Prisma 7 Schemas — the
fhir_resource_linkbridge model lives inprisma-main/models/clinical.prisma:429.prisma:mainandprisma:accare the two PrismaStoreKinds the facade dispatches to. - MinIO infrastructure — bucket topology, retention policy, presigned-URL behavior.
- Canonical deep-dive:
ddx-documentation/97-fhir-resource-facade.md.