Files
vigilcare-clinical/docs/guides/19-fhir-r4-integration-facade.md
T

14 KiB

Guide 19: FHIR R4 Integration Facade

What is FHIR?

FHIR (Fast Healthcare Interoperability Resources, pronounced "fire") is a standard for exchanging healthcare data between systems. If your hospital has an EHR (Electronic Health Record) like Epic or Cerner, a lab system, a pharmacy system, and a monitoring platform, they all need to share patient data. FHIR defines a common language for this — standard data formats (called resources) and standard ways to send them (RESTful HTTP endpoints).

Key concepts:

  • Resource: A structured JSON object representing a healthcare concept. Examples: Patient, Encounter, Observation, MedicationAdministration. Each resource type has a defined set of fields and data types.
  • FHIR R4: The fourth major release of the FHIR standard (Release 4). R4 is the current normative version used by most healthcare systems.
  • Identifier: A system+value pair that identifies a resource in an external system. For example, system: "http://hospital.example/mrn", value: "MRN-001". A patient might have different identifiers in different systems — FHIR uses these to match records across systems.
  • Coding: A code from a standard terminology. system: "http://loinc.org", code: "8867-4" means "Heart rate" in the LOINC vocabulary. system: "http://snomed.info/sct", code: "364075005" also means "Heart rate" in SNOMED CT.
  • Bundle: A collection of resources sent together. A "transaction Bundle" is like a database transaction — all entries succeed or all fail.
  • CapabilityStatement: A resource that describes what a FHIR server can do — which resource types it supports and which operations (create, read, search).
  • OperationOutcome: A FHIR-standard error response. Instead of returning {"error": "..."}, FHIR servers return a structured OperationOutcome resource with severity, issue type, and diagnostic details.

What is a "facade"? This project doesn't implement a full FHIR server. It implements a facade — a thin translation layer that accepts FHIR-formatted requests, maps them to the internal data model, and returns FHIR-formatted responses. The internal database schema is not FHIR-native; the facade translates between the two worlds.


Why FHIR in This Project?

Hospital integration engines (like Mirth Connect or Rhapsody) speak FHIR. They send ADT (Admit/Discharge/Transfer) messages with Patient and Encounter resources, vital signs as Observation resources, and medication records as MedicationAdministration resources. By implementing FHIR endpoints, VigilCareClinical can receive data from any FHIR-capable system without custom integration code for each one.


Architecture Overview

Hospital Systems                    VigilCareClinical
┌──────────────┐                    ┌──────────────────────────────┐
│ EHR (Epic)   │                    │  FHIR Ingest Controller      │
│              │  POST /fhir/R4/    │    │                         │
│ Mirth Connect│  Patient           │    ▼                         │
│ (integration │ ────────────────►  │  PatientFhirMapper           │
│  engine)     │                    │    │ FHIR Patient → internal │
│              │  POST /fhir/R4/    │    ▼                         │
│ Lab System   │  Observation       │  PatientService              │
│              │ ────────────────►  │    │ save to PostgreSQL      │
│ Pharmacy     │                    │    ▼                         │
│              │  POST /fhir/R4/    │  FhirMapper (reverse)        │
│              │  Bundle            │    │ internal → FHIR response│
│              │ ────────────────►  │    ▼                         │
│              │                    │  Return FHIR JSON            │
└──────────────┘                    └──────────────────────────────┘

Supported Resources and Interactions

The CapabilityStatement (available at GET /fhir/R4/metadata, no auth required) declares:

Resource Create Read Search
Patient ✓ (by identifier)
Encounter ✓ (by patient, status)
Observation
MedicationAdministration
Bundle (transaction)

All FHIR endpoints use application/fhir+json as the content type (not application/json), following the FHIR specification.


LOINC Code Mapping

FHIR Observations use standardized codes (LOINC, SNOMED CT) to identify what's being measured. The internal data model uses simpler codes like "HEART_RATE". The LoincCodeMapper translates between them:

public static class LoincCodeMapper
{
    private static readonly Dictionary<string, LoincMapping> _map = new()
    {
        ["8867-4"]  = new("HEART_RATE", "/min"),
        ["8310-5"]  = new("TEMP_C", "Cel", AllowFahrenheit: true),
        ["2823-3"]  = new("POTASSIUM_MEQ_L", "mmol/L"),
        ["2708-6"]  = new("SPO2", "%"),
        ["9279-1"]  = new("RESP_RATE", "/min"),
        ["6690-2"]  = new("WBC_K_UL", "10*3/uL"),
        ["8480-6"]  = new("SYSTOLIC_BP", "mm[Hg]"),
        ["8462-4"]  = new("DIASTOLIC_BP", "mm[Hg]"),
        ["2524-7"]  = new("LACTATE_MMOL_L", "mmol/L"),
        ["777-3"]   = new("PLATELET_K_UL", "10*3/uL"),
        ["1975-2"]  = new("BILIRUBIN_MG_DL", "mg/dL"),
        ["2160-0"]  = new("CREATININE_MG_DL", "mg/dL"),
        ["2703-7"]  = new("PAO2_MMHG", "mm[Hg]"),
        ["80288-7"] = new("GCS_EYE", "{score}"),
        ["80289-5"] = new("GCS_VERBAL", "{score}"),
        ["80290-3"] = new("GCS_MOTOR", "{score}"),
        // ... 19 mappings total
    };

    // SNOMED CT fallbacks for systems that don't use LOINC
    private static readonly Dictionary<string, LoincMapping> _snomedMap = new()
    {
        ["364075005"] = new("HEART_RATE", "/min"),
        ["431314004"] = new("SPO2", "%"),
        ["86290005"]  = new("RESP_RATE", "/min"),
    };

    public static bool TryMap(string system, string code, out LoincMapping mapping)
    {
        if (system.Contains("loinc") && _map.TryGetValue(code, out mapping!))
            return true;
        if (system.Contains("snomed") && _snomedMap.TryGetValue(code, out mapping!))
            return true;
        mapping = null!;
        return false;
    }
}

When a FHIR Observation arrives with code system: "http://loinc.org", code: "8867-4", the mapper translates it to internal code "HEART_RATE" with expected unit "/min".

Unit Conversion

Some hospitals send temperatures in Fahrenheit. The FhirUnitConverter handles this:

public static (decimal Value, string Unit) Normalize(
    string internalCode, decimal value, string? fhirUnit, bool allowFahrenheit)
{
    if (internalCode == "TEMP_C" && allowFahrenheit &&
        (fhirUnit == "[degF]" || fhirUnit == "degF"))
    {
        var celsius = (value - 32m) * 5m / 9m;
        return (Math.Round(celsius, 2), "Cel");
    }
    return (value, fhirUnit ?? "1");
}

The internal system always stores temperature in Celsius. If a FHIR Observation arrives in Fahrenheit, it's converted transparently.


The Observation Mapper

The ObservationFhirMapper handles the most complex mapping because FHIR Observations can have multiple formats:

public async Task<IReadOnlyList<MappedObservation>> ToIngestRequestsAsync(
    Hl7.Fhir.Model.Observation fhir)
{
    var encounterId = await _refs.ResolveEncounterReferenceAsync(fhir.Encounter);
    var source = MapSource(fhir);  // vital-signs → Device, laboratory → Lab
    var recordedAt = fhir.Effective.ToUtcDateTimeOffset() ?? DateTimeOffset.UtcNow;
    var idempotencyKey = fhir.Identifier?.FirstOrDefault()?.Value ?? fhir.Id;

    // FHIR Observations can be single-value or multi-component
    if (fhir.Component?.Count > 0)
    {
        // Multi-component (e.g., blood pressure with systolic + diastolic)
        foreach (var component in fhir.Component)
            results.AddRange(MapSingleCoding(component.Code, component.Value, ...));
    }
    else
    {
        // Single value (e.g., heart rate)
        results.AddRange(MapSingleCoding(fhir.Code, fhir.Value, ...));
    }
}

A FHIR blood pressure Observation has two components (systolic and diastolic). The mapper produces two internal observations from one FHIR resource.


Transaction Bundle Processing

A transaction Bundle groups multiple resources into one atomic operation — like a database transaction. The FhirBundleProcessor handles this:

public async Task<Bundle> ProcessTransactionAsync(Bundle transaction)
{
    var response = new Bundle { Type = Bundle.BundleType.TransactionResponse };

    // Sort entries: Patient first, then Encounter, then everything else
    var entries = transaction.Entry
        .OrderBy(e => Priority(e.Resource))
        .ToList();

    await using var tx = await _db.Database.BeginTransactionAsync();

    foreach (var entry in entries)
    {
        try
        {
            var location = resource switch
            {
                Patient p  => await ProcessPatientAsync(p),
                Encounter e => await ProcessEncounterAsync(e),
                Observation o => await ProcessObservationAsync(o),
                MedicationAdministration m => await ProcessMedAsync(m),
                _ => throw new FhirMappingException("Unsupported resource type")
            };

            response.Entry.Add(new Bundle.EntryComponent
            {
                Response = new Bundle.ResponseComponent
                    { Status = "201 Created", Location = location }
            });
        }
        catch (Exception ex)
        {
            await tx.RollbackAsync();  // all-or-nothing
            response.Entry.Add(/* error entry */);
            return response;
        }
    }

    await tx.CommitAsync();
    return response;
}

Why sort by priority? An Encounter references a Patient, and an Observation references an Encounter. If the Bundle contains all three, the Patient must be created first (so the Encounter can reference it), then the Encounter (so the Observation can reference it). The Priority() function ensures this ordering.

All-or-nothing: If any entry fails, the entire transaction rolls back — no partially-created data.


FHIR Error Responses: OperationOutcome

FHIR has its own error format. Instead of the project's standard ApiResponse<T> envelope, FHIR endpoints return OperationOutcome resources:

public static class FhirOperationOutcomeBuilder
{
    public static OperationOutcome FromException(Exception ex) => ex switch
    {
        FhirMappingException fme => Create(fme.HttpStatus, fme.FhirIssueCode, fme.Message),
        NotFoundException       => Create(404, "not-found", ex.Message),
        ValidationException     => Create(422, "invalid", ex.Message),
        ConflictException       => Create(409, "conflict", ex.Message),
        _                       => Create(500, "exception", "An unexpected error occurred.")
    };
}

The FhirExceptionFilter catches exceptions on /fhir/* paths and converts them to OperationOutcome responses with Content-Type: application/fhir+json:

{
  "resourceType": "OperationOutcome",
  "issue": [{
    "severity": "warning",
    "code": "not-found",
    "diagnostics": "Patient not found."
  }]
}

External Identifier Resolution

When a FHIR Encounter references a Patient as "subject": {"reference": "Patient/MRN-001"}, the system needs to find the internal UUID for that patient. The ExternalResourceIdentifier table maps between external identifiers (hospital MRNs, visit numbers) and internal UUIDs:

resource_type system value internal_id
Patient http://hospital.example/mrn MRN-001 3fa85f64-...
Encounter http://hospital.example/visit VISIT-100 7e4b2a1f-...

This mapping is created when a resource is first ingested and used for all subsequent references. It's what makes the FHIR endpoints idempotent — sending the same Patient twice (same identifier) updates the existing record instead of creating a duplicate.


Authentication for FHIR Endpoints

FHIR endpoints accept two authentication methods (see Guide 15):

  1. JWT token — for admin users accessing via the dashboard or Swagger
  2. API key — for integration engines like Mirth Connect
POST /fhir/R4/Observation
X-Api-Key: dev-integration-key-change-in-production
Content-Type: application/fhir+json

The FHIR API key creates an identity with the Integration role, which has permissions for fhir:ingest, fhir:read, patients:write, encounters:write, observations:ingest, and medications:write.


Key Takeaways

  • FHIR is the healthcare data standard — it defines how to represent patients, encounters, observations, and medications as JSON resources with standard coding systems
  • A facade translates, not stores — the internal database uses its own schema; the FHIR layer maps between FHIR resources and internal entities
  • LOINC and SNOMED CT codes are translated to internal codes"8867-4" (LOINC) becomes "HEART_RATE" internally; SNOMED fallbacks handle systems that don't use LOINC
  • Transaction Bundles are atomic — Patient → Encounter → Observation ordering is enforced, and any failure rolls back the entire bundle
  • OperationOutcome is the FHIR error format — FHIR endpoints return structured error resources instead of the project's standard API envelope
  • External identifiers enable idempotency — the same resource sent twice (same identifier) updates rather than duplicates
  • Unit conversion happens transparently — Fahrenheit temperatures are converted to Celsius during mapping so the internal system works with a single unit