DEV Community

Diven Rastdus
Diven Rastdus

Posted on • Originally published at astraedus.dev

"FHIR for Software Engineers (Not Just Healthcare Devs)"

FHIR for Software Engineers (Not Just Healthcare Devs)

FHIR (Fast Healthcare Interoperability Resources) sounds intimidating. It has its own specification site, a 400-page standard, and a community full of people who say "bundle" and "resource" like everyone knows what they mean.

Here's the thing: FHIR is just REST + JSON with a standardized schema. If you've built a REST API, you already know 80% of what you need. I'm going to show you the practical parts by building real queries against a FHIR server.

What FHIR actually is

FHIR defines a set of "resources" (think: database models) for healthcare data. Each resource has a type, a JSON schema, and a REST endpoint. That's it.

Common resources you'll use:

  • Patient: name, DOB, identifiers, contact info
  • MedicationRequest: a prescription (what drug, what dose, who prescribed it)
  • MedicationStatement: what the patient says they're taking
  • AllergyIntolerance: known allergies and adverse reactions
  • Observation: lab results, vitals, anything measured
  • Condition: diagnoses

Every FHIR server exposes these at predictable URLs:

GET /Patient/123                    # Read one patient
GET /Patient?name=Smith             # Search patients
GET /MedicationRequest?patient=123  # Get prescriptions for a patient
Enter fullscreen mode Exit fullscreen mode

That's standard REST. No custom protocols, no SOAP, no HL7v2 pipe-delimited messages from 1987.

Querying a FHIR server

Any HTTP client works. Here's a real example using curl against a public FHIR server:

# Search for a patient by name
curl "https://hapi.fhir.org/baseR4/Patient?name=Smith&_count=3" \
  -H "Accept: application/fhir+json"
Enter fullscreen mode Exit fullscreen mode

The response is a Bundle -- FHIR's wrapper for collections:

{
  "resourceType": "Bundle",
  "type": "searchset",
  "total": 1247,
  "entry": [
    {
      "resource": {
        "resourceType": "Patient",
        "id": "12345",
        "name": [
          {
            "family": "Smith",
            "given": ["John", "Michael"]
          }
        ],
        "birthDate": "1985-03-15",
        "gender": "male"
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Key patterns:

  • resourceType tells you what you're looking at
  • entry[].resource is the actual data
  • Names are arrays (people have multiple names -- maiden, married, legal)
  • given is also an array (first + middle names)

The TypeScript client

Raw HTTP works, but for production code use a typed client. Here's how to set one up:

import Client from "fhir-kit-client";

const fhirClient = new Client({
  baseUrl: "https://your-fhir-server.com/fhir",
});

// Read a single patient
const patient = await fhirClient.read({
  resourceType: "Patient",
  id: "12345",
});

console.log(patient.name[0].family); // "Smith"
console.log(patient.birthDate);       // "1985-03-15"
Enter fullscreen mode Exit fullscreen mode

Searching returns Bundles. You'll write this pattern constantly:

// Get all medications for a patient
const bundle = await fhirClient.search({
  resourceType: "MedicationRequest",
  searchParams: {
    patient: patientId,
    status: "active",
    _count: "100",
  },
});

// Extract resources from the bundle
const medications = (bundle.entry || [])
  .map((entry: any) => entry.resource)
  .filter((r: any) => r.resourceType === "MedicationRequest");
Enter fullscreen mode Exit fullscreen mode

That bundle.entry || [] guard is important. Empty search results return a Bundle with no entry field, not an empty array. This trips up everyone at least once.

Understanding medication data

This is where it gets interesting (and messy). A MedicationRequest looks like this:

{
  "resourceType": "MedicationRequest",
  "id": "med-001",
  "status": "active",
  "intent": "order",
  "medicationCodeableConcept": {
    "coding": [
      {
        "system": "http://www.nlm.nih.gov/research/umls/rxnorm",
        "code": "860975",
        "display": "Metformin 500mg Oral Tablet"
      }
    ],
    "text": "Metformin 500mg"
  },
  "subject": {
    "reference": "Patient/12345"
  },
  "dosageInstruction": [
    {
      "text": "Take 1 tablet by mouth twice daily",
      "timing": {
        "repeat": {
          "frequency": 2,
          "period": 1,
          "periodUnit": "d"
        }
      },
      "doseAndRate": [
        {
          "doseQuantity": {
            "value": 500,
            "unit": "mg",
            "system": "http://unitsofmeasure.org"
          }
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The medication name can live in three places:

  1. medicationCodeableConcept.text -- human-readable
  2. medicationCodeableConcept.coding[0].display -- from a coding system
  3. medicationReference.reference -- points to a separate Medication resource

You need to handle all three. In production, I normalize drug names by stripping dose info, salt forms, and route abbreviations:

function normalizeDrugName(name: string): string {
  return name
    .toLowerCase()
    // Remove dose info: "metformin 500mg" -> "metformin"
    .replace(/\s*\d+\.?\d*\s*(mg|mcg|g|ml|units?)\b.*/i, "")
    // Remove salt forms: "metformin hydrochloride" -> "metformin"
    .replace(
      /\s+(hydrochloride|hcl|sulfate|sodium|potassium|acetate|besylate|maleate|fumarate|calcium|citrate)\b/gi,
      ""
    )
    // Remove route: "metformin po" -> "metformin"
    .replace(/\s+(iv|im|po|pr|sl|td|top|inh)\b.*/i, "")
    .trim();
}

normalizeDrugName("Metformin Hydrochloride 500mg BID");
// -> "metformin"
Enter fullscreen mode Exit fullscreen mode

This matters because hospitals, pharmacies, and patient self-reports all describe the same drug differently. "Metformin 500mg," "metformin hydrochloride 500mg oral tablet," and "Glucophage 500" are all metformin. Without normalization, your comparison logic sees three different drugs.

Search parameters you'll actually use

FHIR search has its own syntax, but it maps to query parameters:

# Patients born after 1990
GET /Patient?birthdate=gt1990-01-01

# Active prescriptions for patient 123
GET /MedicationRequest?patient=123&status=active

# Allergies marked as high criticality
GET /AllergyIntolerance?patient=123&criticality=high

# Include the referenced medication resource in results
GET /MedicationRequest?patient=123&_include=MedicationRequest:medication

# Paginate with _count and _offset
GET /Patient?name=Smith&_count=20&_offset=40
Enter fullscreen mode Exit fullscreen mode

The _include parameter is powerful. Without it, a MedicationRequest that uses medicationReference (instead of inline medicationCodeableConcept) just gives you a reference like Medication/abc. With _include, the server resolves the reference and returns the Medication resource in the same Bundle.

Date comparisons use prefixes: gt (greater than), lt (less than), ge, le, eq. birthdate=gt1990-01-01 means born after January 1, 1990.

Building on top of FHIR

Once you can read FHIR data, you can build tools. Here's a real-world example: a medication reconciliation system that compares what the hospital prescribed vs. what the pharmacy dispensed vs. what the patient reports taking.

async function reconcileMedications(patientId: string) {
  // Pull from hospital EHR (MedicationRequest = prescriptions)
  const prescribed = await fhirClient.search({
    resourceType: "MedicationRequest",
    searchParams: { patient: patientId, status: "active" },
  });

  // Pull from pharmacy (MedicationStatement = what was dispensed/taken)
  const reported = await fhirClient.search({
    resourceType: "MedicationStatement",
    searchParams: { patient: patientId, status: "active" },
  });

  const prescribedMeds = extractMedNames(prescribed);
  const reportedMeds = extractMedNames(reported);

  // Find discrepancies
  const missingFromPharmacy = prescribedMeds.filter(
    (med) => !reportedMeds.some(
      (r) => normalizeDrugName(r) === normalizeDrugName(med)
    )
  );

  return {
    prescribed: prescribedMeds,
    reported: reportedMeds,
    discrepancies: missingFromPharmacy,
  };
}
Enter fullscreen mode Exit fullscreen mode

This is the core of a medication safety tool. Patients fall through the cracks at care transitions -- discharged from hospital with new prescriptions, but the pharmacy never gets updated, or the patient stops taking something and nobody notices. FHIR gives you the data layer to catch these gaps programmatically.

Running your own FHIR server

For development, use HAPI FHIR -- the reference implementation:

docker run -p 8080:8080 hapiproject/hapi:latest
Enter fullscreen mode Exit fullscreen mode

That gives you a full FHIR R4 server at http://localhost:8080/fhir with a web UI for browsing resources. Load test data with Synthea (synthetic patient generator):

# Generate 100 synthetic patients
java -jar synthea-with-dependencies.jar -p 100 --exporter.fhir.export true

# Upload the generated bundles
for f in output/fhir/*.json; do
  curl -X POST "http://localhost:8080/fhir" \
    -H "Content-Type: application/fhir+json" \
    -d @"$f"
done
Enter fullscreen mode Exit fullscreen mode

Synthea generates realistic patient histories -- medications, conditions, allergies, lab results -- all in FHIR format. I loaded 281 synthetic patients this way for testing. Each patient comes with a complete medical timeline you can query.

Things that tripped me up

Bundle pagination. Large result sets come back paginated. The Bundle has a link array with relation: "next" pointing to the next page. If you only process the first page, you'll silently miss data.

Reference resolution. FHIR uses references between resources: "subject": {"reference": "Patient/123"}. The reference is a relative URL, not an ID. If you're parsing references to extract IDs, split on / and take the last segment.

FHIR versions matter. R4 and R5 have different field names for some resources. Check which version your server runs (usually in the CapabilityStatement at /metadata). Most production servers are still on R4.

CodeableConcept vs. Coding. A CodeableConcept contains a text field (human-readable) and a coding array (machine-readable codes from systems like RxNorm, SNOMED, ICD-10). Always prefer the text field for display. Use coding when you need to match across systems.

Empty bundles. An empty search result is a Bundle with total: 0 and no entry field. Not entry: []. Guard against undefined.

When to use FHIR

FHIR makes sense when:

  • You're integrating with healthcare systems (EHRs, pharmacies, labs)
  • You need a standard data model for health data
  • Multiple systems need to exchange patient information
  • You're building tools that operate on medical records

It doesn't make sense when:

  • You're building a simple health tracker app (just use your own schema)
  • You don't need interoperability with existing healthcare systems
  • Your data doesn't map to FHIR's resource types

The standard is designed for interoperability. If you're not integrating with external systems, the overhead of FHIR compliance isn't worth it.

Resources

  • FHIR R4 Spec -- the actual standard, surprisingly readable
  • HAPI FHIR -- Java-based reference server, runs in Docker
  • Synthea -- synthetic patient generator
  • fhir-kit-client -- TypeScript/JS FHIR client
  • SMART on FHIR -- OAuth2 profile for FHIR apps (how real EHR integrations authenticate)

I build production AI systems, including healthcare tools. If you're working with medical data, I'm at astraedus.dev.

Top comments (0)