02-data-and-fhirwave: W1filled27 citations

Aggregate Storage Pattern — MinIO + Prisma + FHIR via ResourceFacade

Audiences: developer, internal

Aggregate Storage Pattern — MinIO + Prisma + FHIR via ResourceFacade

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 the fhir_resource_link bridge 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 Binary resources max out HAPI well before clinical reality (DICOM studies, multi-megabyte PDFs, audio recordings, video). The FHIR record stores metadata + a url pointer; 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 useFactory provider. Direct FHIR_CLIENT or direct StorageService access is allowed for transitional code, but new resources should go through ResourceFacade<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_link row in ddx_api_main keyed (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 (legacy FhirResourceService consumer) or the Tier-0 hook swallowed a Prisma error — check the BaseLinkHooks log lines.

Architecture

One logical "resource" = aggregate of 3 engines.

Rendering diagram…

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-s3 is the actual client; the minio npm package is retained for legacy code paths only — see ddx-api/CLAUDE.md "MinIO / File Storage").
  • One descriptor per resource type declares storageBindings[]. The StoreKind union covers the five engines today (fhir, prisma:main, prisma:acc, minio, external) — defined at core/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 — see core/resource-descriptor.ts:79-89. The port itself is bucket-naming agnostic; clinic-bucket resolution lives at platform/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=false and nothing else runs. After FHIR succeeds, Tier-0 and Tier-1 hooks fire with swallowed errorsresource-facade.ts:257-263. A failed bridge-row upsert leaves the FHIR record orphan-but-canonical; observability lives in the BaseLinkHooks logger (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)

  1. Service injects INVOICE_RESOURCE_FACADE token → a ResourceFacade<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.
  2. Caller invokes facade.create(invoice, 'ddx-hamburg-clinic', orgId).
  3. Primary binding is fhirIFhirClient.create(...) writes to HAPI with X-Clinic-ID: ddx-hamburg-clinic. HAPI's ClinicPartitionInterceptor routes the write to partition 1 (Hamburg). HAPI assigns the FHIR id.
  4. If FHIR fails, the facade returns {ok:false, error} — Tier-0 and Tier-1 NEVER run. The state is consistent (nothing was written).
  5. If FHIR succeeds, Tier-0 makeBaseLinkHooks.afterCreate upserts fhir_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.
  6. 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.
  7. The facade returns FacadeResult<Invoice> with the persisted resource. Caller narrows if (!result.ok) … — no casts on result.value (see FHIR ResourceFacade §"Patterns to follow").

Read path

There is no read-aggregator today. Each consumer fetches what it needs:

  1. 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.
  2. A read by Prisma-side criteria (e.g. "all invoices for org X paid in May") starts in Prisma (fhir_resource_link joined 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.
  3. 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 the url pointer; 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 url will 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 FHIR url — 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.create and immediately joins through fhir_resource_link may race the Tier-0 hook if it dispatches on the same tick. The pragmatic fix is to use the FacadeResult.value returned from create, 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-332create() implementation: dispatch to primary store, then runMutationHook('afterCreate', …). FHIR-first ordering is hard-coded in this sequence.
  • ddx-api/src/platform/fhir-resources/core/resource-facade.ts:249-265runMutationHook() 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-68FacadeResult<T> discriminated union (ok:true carries value, ok:false carries error). Only the primary-op error path produces ok:false; hook failures NEVER do.
  • ddx-api/src/platform/fhir-resources/core/resource-descriptor.ts:43-51StoreKindSchema — the five engines aggregate storage can target.
  • ddx-api/src/platform/fhir-resources/core/resource-descriptor.ts:79-89 — closed 7-enum MinIO bucket validator (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-115makeBaseLinkHooks(resourceType, prismaMainClient).afterCreate upserts fhir_resource_link on the (resourceType, fhirId, clinicId) unique index.
  • ddx-api/src/platform/fhir-resources/core/base-link-hooks.ts:108-114the swallow: link-row upsert failures are caught, logged via Logger('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-131afterDelete is 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:429model FhirResourceLink definition. Lives in prisma-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:36MINIO_CLIENT = Symbol('MINIO_CLIENT') DI token.
  • ddx-api/src/platform/fhir-resources/core/minio-port.ts:101-140MinioPort interface: presignedPutUrl / presignedGetUrl / headObject / deleteObject. Intentionally tenant-AGNOSTIC — receives fully-qualified bucket + 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 in StorageService").
  • ddx-api/src/platform/storage/storage/utils.ts:14getClinicBucket(slug, type) resolves {slug}-{type} (or platform-{type} when slug === 'default').
  • ddx-api/src/platform/storage/storage/utils.ts:127getBucketTypeForAttachment(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 legacy FhirResourceService / FhirSearchService classes — 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_link is 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 umbrella FhirService class was retired 2026-05-17. Transitional code that cannot adopt the facade must inject FHIR_CLIENT directly — and accept that it produces NO bridge row.
  • Aggregate-storage failure modes you will actually see:
    1. FHIR write fails → FacadeResult.ok=false. Nothing else runs. State is clean. Caller surfaces the error.
    2. 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.
    3. FHIR write succeeds, Tier-1 MinIO presigned-PUT URL minted, client fails to PUT bytes → FHIR record carries a url pointing at a nonexistent object. The FHIR resource is still canonical; the blob is missing. Detect via MinioPort.headObject sweep against fhir_resource_link-discovered URLs.
    4. 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.
  • There is no atomic delete across engines. afterDelete is a no-op stub. A "delete invoice" call removes the FHIR resource and orphans the bridge row + blob. Adapters that need real cleanup must override afterDelete with the fhirId captured at beforeDelete time — pattern documented at core/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} (or platform-{type} for shared/default-slug resources). The same enum is the Zod-validated bucket discriminator 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 StorageService in platform/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 at minio-port.ts:14-27.
  • Reconciliation tools: There is no formal reconciler today. Operational practice when a defect surfaces:
    1. SELECT * FROM fhir_resource_link WHERE resourceType=... AND fhirId=... to confirm bridge presence.
    2. GET /fhir/<Type>/<fhirId> with X-Clinic-ID to confirm canonical FHIR record.
    3. MinioPort.headObject(bucket, key) (or mc stat against 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".
  • 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 same clinicId that keys the Tier-0 bridge row.
  • Prisma 7 Schemas — the fhir_resource_link bridge model lives in prisma-main/models/clinical.prisma:429. prisma:main and prisma:acc are the two Prisma StoreKinds the facade dispatches to.
  • MinIO infrastructure — bucket topology, retention policy, presigned-URL behavior.
  • Canonical deep-dive: ddx-documentation/97-fhir-resource-facade.md.