How It Started
A few months ago, I was browsing remote FHIR engineer roles in Australia. Every single job description mentioned the same thing: experience integrating HL7 v2 with FHIR R4.
This makes sense. Australian hospitals – like most hospitals worldwide – still run on HL7 v2. Patient admissions, lab results, orders – they all flow as pipe-delimited messages over MLLP. But the direction is clear: the Australian Digital Health Agency (ADHA) is pushing hard toward FHIR R4 through the Sparked FHIR Accelerator and the AU Core Framework. Somebody has to build the bridge between these two worlds.
I decided to build one myself: a working pipeline that takes real HL7 v2 maternity messages and transforms them into valid, AU-profiled FHIR R4 resources. Not a toy. Not a tutorial. A real integration that I could show in interviews and say: “I built this. Here’s how it works. Here’s where it breaks.”
GitHub: budityw23/maternity-hl7-to-fhir-pipeline
This article walks through how I designed it – including the mistakes I made along the way.
My Initial (Naive) Approach
My first instinct was simple. HL7 v2 comes in, I parse it in Python, I build a FHIR JSON, I POST it to a FHIR server. Done.
Hospital Maternity System
|
| HL7 v2 messages (MLLP)
v
Python Script
| -> parse HL7
| -> build FHIR JSON manually
| -> POST to FHIR server
v
HAPI FHIR Server
Enter fullscreen mode Exit fullscreen mode
I wrote a quick script. It worked for a single ADT^A01 (patient admission) message. I felt good about it. Then the problems started:
- No MLLP handling: My script was reading messages from a file. Real hospital systems don’t send files – they send HL7 over MLLP (a TCP-based protocol with specific framing). My script had no way to receive live messages.
-
Brittle parsing: I was splitting on
|and counting positions. One unexpected empty field and the whole mapping shifted. A message with a maiden name inPID-6but nothing inPID-5.4(suffix) would silently put the wrong data in the wrong FHIR field. - No validation: I was constructing FHIR JSON by hand as Python dicts. Typo in a field name? Missing a required element? The script happily sent invalid resources to the FHIR server, which sometimes accepted them and sometimes threw cryptic errors.
- No multi-resource linking: A maternity visit involves a Patient, an Encounter, Observations (blood pressure, weight, fetal heartbeat), and a Condition (pregnancy diagnosis). These resources need to reference each other. My script created them independently with no linking – the Observation had no idea which Encounter it belonged to.
- No error trail: If the Patient was created successfully but the Observation failed, I had no record of what went wrong, no correlation between the original message and the failure, and no way to replay the failed message.
I was building for the happy path again – the same mistake I described in my vaccine appointment system article. One clean message in, one clean resource out. But healthcare data is never clean.
Rethinking the Architecture
The core insight was: this is not a single transformation. It’s a pipeline with distinct responsibilities.
Receiving MLLP messages is a different concern from parsing HL7. Parsing is different from mapping. Mapping is different from FHIR validation. Validation is different from persistence. Each stage can fail independently, and each needs its own error handling.
I also realized that an integration engine like Mirth Connect already solves the hardest part – MLLP reception, HL7 parsing, and message routing. Fighting that battle in raw Python was wasting time on a solved problem.
Here’s the redesigned architecture:
Hospital Maternity System
|
| HL7 v2.5 messages over MLLP:
| ADT^A01 -> patient admitted for antenatal care
| ORM^O01 -> antenatal checkup ordered
| ORU^R01 -> checkup results (BP, weight, fetal heartbeat)
v
Mirth Connect 4.5 (port 6661)
| -> receives MLLP, parses HL7 natively
| -> extracts key segments (PID, PV1, OBX, DG1, OBR)
| -> builds structured JSON payload
| -> routes by message type to correct endpoint
v
Python 3.11 + FastAPI (port 8000)
| -> receives typed JSON from Mirth
| -> maps fields to FHIR R4 with AU Base profiles
| -> validates via fhir.resources (Pydantic models)
| -> conditional PUT/POST to HAPI FHIR (idempotent)
| -> structured JSON logging with correlation ID
v
HAPI FHIR Server v7.0.3 (port 8080)
| -> stores Patient, Condition, Observation, Encounter
| -> supports $validate for profile conformance
| -> serves FHIR REST API
v
Accessible via standard FHIR queries
Enter fullscreen mode Exit fullscreen mode
The key difference: every component does one thing well, and failure at any stage doesn’t corrupt the others.
The Improved Design
1. Mirth Connect: The HL7 Gatekeeper
Mirth Connect listens on MLLP port 6661 and handles the protocol-level complexity that I was trying to reinvent in Python. It natively understands HL7 v2.5 segment structure, so I can reference msg['PID']['PID.5']['PID.5.1'] directly in a JavaScript transformer – no string splitting, no position counting.
Mirth does three jobs:
-
Receives and parses: Handles MLLP framing (the
\x0bheader and\x1c\x0dtrailer), parses segments, and sends ACK/NACK responses back to the sender. -
Extracts and restructures: A JavaScript transformer pulls the fields I need and builds a clean JSON payload. This is where I handle HL7 quirks – like the fact that
PID-8encodes sex asF/M/Ubut FHIR usesfemale/male/unknown. -
Routes by message type:
ADT^A01goes to/fhir/Patient,ORM^O01goes to/fhir/Encounter,ORU^R01goes to/fhir/Observation/bundle. Each endpoint in FastAPI knows exactly what shape of data to expect.
Why not do everything in Mirth? You can – plenty of teams build entire FHIR transformations in Mirth’s JavaScript engine. But I wanted the FHIR validation layer in Python using fhir.resources, which gives me Pydantic-based validation against the full FHIR R4 spec. If I accidentally set Observation.status to "done" instead of "final", Pydantic catches it before it ever reaches the FHIR server. You don’t get that level of type safety in Mirth’s JavaScript.
2. The Mapping Layer: Where the Real Work Happens
This is the heart of the pipeline. Each HL7 message type maps to one or more FHIR resources:
ADT^A01 (Patient Admission) produces:
-
Patient – from
PIDsegment (name, DOB, sex, address, IHI identifier) -
Condition – from
DG1segment (pregnancy diagnosis, ICD-10-AM coded)
ORM^O01 (Order) produces:
-
Encounter – from
PV1segment (visit number, class, admission date, location, attending doctor) - References back to existing Patient (looked up by MRN)
ORU^R01 (Observation Result) produces:
-
Observation – from
OBXsegments (vital signs: blood pressure, weight, fetal heartbeat) - References back to existing Patient (by MRN) and Encounter (by visit number)
Here’s where the complexity lives. A simple mapping like PID-5.1 -> Patient.name[0].family is straightforward. But consider blood pressure. In HL7 v2, systolic and diastolic come as two separate OBX segments:
OBX|1|NM|8480-6^Systolic BP^LN||120|mm[Hg]|90-120|N|||F
OBX|2|NM|8462-4^Diastolic BP^LN||80|mm[Hg]|60-80|N|||F
Enter fullscreen mode Exit fullscreen mode
In FHIR R4, blood pressure is a single Observation resource with LOINC code 85354-9 (Blood pressure panel) containing two component[] entries – one for systolic, one for diastolic. The transformer detects consecutive BP-related OBX segments (codes 8480-6 and 8462-4), merges them into a single panel Observation with the AU Base blood pressure profile (au-vitalsigns-bloodpressure), and applies the most conservative status and worst-case interpretation across both readings. Orphan systolic or diastolic readings (without their pair) fall back to individual Observations. This is the kind of domain logic that makes healthcare integration genuinely hard – it’s not just field-to-field mapping.
3. Australian Localisation
Since this targets the AU market, the FHIR resources conform to AU Base profiles on all resource types:
Resource Profile Patienthttp://hl7.org.au/fhir/StructureDefinition/au-patient
Condition
http://hl7.org.au/fhir/StructureDefinition/au-condition
Encounter
http://hl7.org.au/fhir/StructureDefinition/au-encounter
Observation (BP)
http://hl7.org.au/fhir/StructureDefinition/au-vitalsigns-bloodpressure
Additional AU-specific details:
- Patient identifiers include the IHI (Individual Healthcare Identifier) with system URI
http://ns.electronichealth.net.au/id/hi/ihi/1.0 - Diagnosis coding uses ICD-10-AM (Australian Modification) rather than plain ICD-10
- Clinical terminology uses SNOMED CT-AU where applicable
- Addresses use 4-digit Australian postcodes and state codes (NSW, VIC, QLD, etc.)
- All FHIR datetimes include the
+10:00AEST timezone offset
This isn’t just cosmetic. AU Base profiles have specific cardinality and terminology constraints. An au-patient resource without a valid identifier type code will fail validation against the profile. Getting this right demonstrates that I understand the difference between generic FHIR and FHIR as it’s actually used in Australian healthcare.
4. Idempotency: Handling Duplicate Messages
Hospital systems sometimes send the same message twice – network retries, interface engine restarts, manual resends. The pipeline handles this through conditional operations:
-
Patient: Conditional PUT using
?identifier=http://hospital.local/mrn|{MRN}– re-sending the same ADT^A01 updates the existing Patient rather than creating a duplicate. - Encounter: Conditional PUT using the visit number identifier – same ORM^O01 twice produces one Encounter.
- Condition and Observation: Created via POST (new resource each time), but because they reference the idempotent Patient/Encounter, the referential links remain consistent.
This approach trades off perfect idempotency on Conditions/Observations for simplicity. In a production system, you might add conditional logic based on a combination of patient reference, code, and effective date. For a portfolio project, the Patient/Encounter idempotency demonstrates the pattern clearly.
5. Failure Scenarios and Observability
Just like with the vaccine system, I forced myself to think about what breaks:
- Malformed HL7 message: Mirth rejects it at the protocol level and sends a NACK. The message never reaches FastAPI. Logged for review.
-
Missing required fields: FastAPI’s Pydantic models catch empty MRNs, invalid gender codes, and missing observation codes at the payload boundary. Returns RFC 7807
application/problem+jsonwith a 422 status. -
FHIR validation failure: The
fhir.resourcesPydantic model rejects the resource – maybeObservation.statushas an invalid value, or a required CodeableConcept is missing. The error includes exactly which field failed and why. - Patient not found for downstream messages: An ORM^O01 or ORU^R01 arrives before the ADT^A01. The pipeline looks up the Patient by MRN, fails to find it, and returns a 422: “Patient not found for MRN=X. Send ADT^A01 first.”
-
HAPI FHIR rejects the resource: The FastAPI layer catches the HTTP error, writes the original payload to a dead-letter file in
./deadletter/(with the correlation ID as filename prefix), and returns a 502 with the HAPI error details.
Correlation ID ties everything together. Every message gets a UUID (either from Mirth via X-Correlation-ID header, or auto-generated by middleware). The correlation ID appears in every structured JSON log line, in error responses, in dead-letter filenames, and in response headers. When something fails at 3am, you grep one ID and see the entire message lifecycle.
Structured JSON logging ensures every log line is machine-parseable – timestamp, level, logger name, message, and correlation ID in every entry. Noisy libraries (uvicorn access logs, httpx, httpcore) are suppressed. No PHI (names, dates of birth) in logs – only identifiers like MRN and correlation ID at INFO level.
6. Server-Side Validation
Client-side validation via fhir.resources catches structural errors – wrong field types, missing required elements, invalid enum values. But it can’t validate against AU Base profile constraints. For that, the pipeline integrates with HAPI’s $validate operation.
A standalone endpoint (POST /fhir/validate/{resource_type}) accepts any FHIR resource and returns the raw OperationOutcome from HAPI – useful for testing profile conformance during development.
For production use, a VALIDATE_BEFORE_PERSIST config flag enables pre-persist validation: every resource is validated via $validate before being written to HAPI. If validation finds errors or fatal issues, the persist is blocked and a 422 is returned with diagnostic messages. This is off by default (the demo seeds placeholder StructureDefinitions, not the full AU Base IG), but the plumbing is in place for when a real terminology server and IG package are loaded.
System Components
Here’s the full component view:
+----------------------------------------------------------+
| Docker Compose |
| (maternity-net) |
| |
| +-------------+ +--------------+ +------------+ |
| | Mirth | | FastAPI | | HAPI FHIR | |
| | Connect |--->| Python |--->| Server | |
| | 4.5 | | 3.11+ | | v7.0.3 | |
| | | | | | | |
| | Port: 6661 | | Port: 8000 | | Port: 8080 | |
| | (MLLP) | | (HTTP) | | (FHIR REST)| |
| +-------------+ +--------------+ +------------+ |
| |
| Services: |
| - mirth (nextgenhealthcare/connect:4.5) |
| - fastapi (python:3.11-slim + FastAPI + uvicorn) |
| - hapi (hapiproject/hapi:v7.0.3, H2 embedded) |
+----------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode
- Mirth Connect 4.5 – The integration engine. Handles MLLP, parses HL7, routes by message type, sends structured JSON downstream.
-
Python FastAPI – The transformation and validation layer. Endpoints for Patient (
/fhir/Patient), Encounter (/fhir/Encounter), Observations (/fhir/Observation/bundle), validation (/fhir/validate/{resource_type}), and health (/health). Dedicated transformer modules (patient.py,condition.py,observation.py,encounter.py). Usesfhir.resourcesfor Pydantic-based FHIR R4 validation. -
HAPI FHIR Server v7.0.3 – The FHIR repository. Stores resources, serves the REST API, supports
$validatefor profile conformance. Uses embedded H2 database for the demo (Postgres swap documented for production).
Everything runs with docker compose up. One command, full pipeline.
Repository Structure
maternity-hl7-to-fhir/
|-- docker-compose.yml
|-- fastapi/
| |-- Dockerfile
| |-- pyproject.toml
| `-- app/
| |-- main.py # FastAPI routes + lifespan
| |-- config.py # pydantic-settings (env vars)
| |-- errors.py # RFC 7807 problem+json
| |-- logging_setup.py # Structured JSON logging
| |-- middleware.py # X-Correlation-ID middleware
| |-- models/ # Pydantic payload models
| | |-- adt_payload.py # ADT^A01 input
| | |-- orm_payload.py # ORM^O01 input
| | `-- oru_payload.py # ORU^R01 input
| |-- transformers/ # HL7 -> FHIR mapping
| | |-- patient.py # -> Patient resource
| | |-- condition.py # -> Condition resource
| | |-- encounter.py # -> Encounter resource
| | `-- observation.py # -> Observation (+ BP panel)
| |-- clients/ # HAPI FHIR client
| | |-- hapi_client.py # Upsert/create + $validate + profile seeding
| | |-- patient_resolver.py # MRN -> Patient ID lookup
| | `-- encounter_resolver.py # Visit number -> Encounter ID lookup
| `-- valuesets/ # Terminology mappings
| |-- hl7_to_fhir_gender.py
| |-- hl7_to_fhir_encounter.py
| `-- hl7_to_fhir_observation.py
|-- hapi/
| |-- Dockerfile
| |-- application.yaml
| `-- HealthCheck.java
|-- mirth/
| |-- channels/
| `-- code_templates/
|-- samples/ # Synthetic HL7 messages
|-- scripts/
| |-- mllp_send.py # MLLP test client
| |-- demo.sh # Interactive walkthrough
| `-- reset.sh # Full reset (down -v + clean)
|-- tests/
| |-- unit/ # 153 unit tests
| `-- integration/ # 20 integration tests
|-- deadletter/ # Failed message store (gitignored)
|-- logs/ # Runtime logs (gitignored)
`-- .github/workflows/ci.yml # CI: lint + type check + test
Enter fullscreen mode Exit fullscreen mode
What I Learned
Building this project taught me things I couldn’t have learned from reading the FHIR spec alone.
HL7 v2 is deceptively simple. The pipe-delimited format looks easy to parse until you encounter repeating fields, component separators, escape characters, and the fact that different hospitals implement the same message type differently. An integration engine like Mirth saves enormous time here – it’s purpose-built for this chaos.
FHIR validation is your safety net, not your enemy. My naive approach skipped validation because it felt like extra work. In practice, the Pydantic models from fhir.resources caught errors that would have taken hours to debug at the FHIR server level. A missing Observation.code.coding[0].system? Pydantic tells you immediately. HAPI FHIR gives you a generic 400. Adding server-side $validate on top catches profile conformance issues that client-side validation can’t – like missing AU Base constraints.
Idempotency is not optional in healthcare integration. Hospital systems send duplicate messages routinely. Conditional PUT with identifier queries means the second submission of the same ADT^A01 updates the existing Patient instead of creating a duplicate. This is a simple pattern that eliminates an entire class of production incidents.
Correlation IDs are the single most useful observability investment. One UUID, generated at the entry point, propagated through every log line, every error response, every dead-letter file. When a message fails at 3am, you grep one string and see everything that happened. Without it, you’re correlating timestamps across multiple services and hoping the clocks are synchronized.
The AU localisation is what separates a tutorial from a portfolio project. Anyone can map PID-5 to Patient.name. Knowing that Australian systems use IHI identifiers, ICD-10-AM coding, AU Base profile URLs, and SNOMED CT-AU – and encoding all of that correctly in the FHIR resources – shows real domain expertise. In interviews, this is what gets follow-up questions.
Start with failure scenarios, not the happy path. This is the same lesson from my vaccine appointment system design, and it applies everywhere. The first question to ask about any integration is not “how does it work?” but “what happens when it breaks?” RFC 7807 error responses, dead-letter queues, and structured logging aren’t glamorous features – but they’re the difference between “it works in a demo” and “I’d trust this in production.”
Testing and Quality
The pipeline has 173 automated tests (153 unit + 20 integration) at 90% line coverage:
- Unit tests cover every transformer, valueset mapping, payload validator, error handler, logging formatter, and middleware component in isolation.
-
Integration tests exercise the full FastAPI ASGI stack (routes, middleware, error handlers) with mocked HAPI responses using
respx. Tests cover happy paths, validation failures, missing upstream resources, and correlation ID propagation. - CI pipeline runs ruff (linting), mypy –strict (type checking), and pytest with a coverage gate of 80% on every push.
What’s Next
This pipeline covers 4 FHIR resources, 3 HL7 message types, and AU Base profile conformance – deliberately scoped to be buildable as a portfolio project while demonstrating real integration patterns. Extensions I’m considering:
-
Full AU Core IG loading – replacing the placeholder StructureDefinitions with the complete AU Base Implementation Guide package for meaningful
$validateresults - Terminology validation – integrating with a FHIR terminology server (like Ontoserver) to validate SNOMED CT-AU and LOINC codes at transformation time
- Monitoring dashboard – a simple frontend showing message throughput, transformation success/failure rates, and recent errors
- Additional message types – ORU^R30 (unsolicited lab), ADT^A08 (update patient), ADT^A03 (discharge)
The full source code, documentation, and Docker Compose setup are on GitHub: maternity-hl7-to-fhir-pipeline.
If you’re working on HL7-to-FHIR integrations or preparing for health IT interviews, I’d like to hear what challenges you’ve run into. You might also find my earlier article on the National Vaccine Appointment & Administration System useful – it covers similar design thinking around failure handling and rollback patterns.
Source code: github.com/budityw23/maternity-hl7-to-fhir-pipeline
Tags: #fhir #healthit #hl7 #architecture
답글 남기기