When I started building my Maternity HL7-to-FHIR Pipeline, my first instinct was to do everything in Python. Parse the HL7 message, map the fields, validate the FHIR resource, persist it, all in one FastAPI service. It was clean. It was simple. It was wrong.
Some Background
I've been building an open-source pipeline that takes HL7 v2.5 messages, the kind hospital maternity systems still send over raw TCP connections, and turns them into FHIR R4 resources with Australian Base profiles. Patient admissions, lab orders, vital signs. The sort of data that's been flowing between hospital systems in pipe-delimited text since the 1990s, now mapped into modern healthcare APIs.
If you're curious about the full project, I wrote a detailed walkthrough in Bridging Legacy Hospital Messages to Modern Healthcare APIs. That article covers the end-to-end architecture, but here's the short version:
Hospital Maternity System
|
| HL7 v2.5 messages (ADT, ORM, ORU)
| over MLLP (raw TCP, port 6661)
v
┌─────────────────────────┐
│ Mirth Connect 4.5 │ Receives MLLP, parses HL7,
│ (Integration Engine) │ extracts fields, routes
└───────────┬─────────────┘ by message type
|
| Clean JSON over HTTP
v
┌─────────────────────────┐
│ FastAPI + Python 3.11 │ Pydantic validation, FHIR R4
│ (Transformation Layer) │ mapping, AU Base profiles
└───────────┬─────────────┘
|
| FHIR R4 resources
| (conditional PUT/POST)
v
┌─────────────────────────┐
│ HAPI FHIR Server 7.0.3 │ Persistence + FHIR API
└─────────────────────────┘
Three containers, one docker compose up. Mirth handles the legacy protocol world, FastAPI handles the FHIR world, HAPI stores everything.
But this article isn't about the what. It's about one particular why: why the pipeline has two services doing the transformation work instead of one.
The "Just Parse It in Python" Phase
My initial architecture looked like this:
Hospital System --MLLP--> Python Script --> HAPI FHIR Server
I used python-hl7 to split messages on | and count field positions. For a single ADT^A01 (patient admission) message, it worked fine. I could pull the patient name from PID-5, the MRN from PID-3, the gender from PID-8, and build a FHIR Patient resource from it.
Then I tried a real-ish maternity workflow (an admission, an order, and a set of vitals) and things fell apart quickly.
Five Problems That Changed My Mind
1. MLLP Is Not HTTP
Hospital systems don't send HL7 over HTTP. They send it over MLLP (Minimum Lower Layer Protocol), which is a TCP socket protocol with specific framing bytes (\x0b at the start, \x1c\x0d at the end). The sender expects an ACK or NACK response in HL7 format, not an HTTP status code.
Building an MLLP listener in Python is possible. Libraries like aioml7 exist. But you're now maintaining a custom TCP server alongside your HTTP API server, handling connection pooling, timeouts, and HL7 acknowledgment generation. That's a lot of infrastructure code that has nothing to do with your actual transformation logic.
Mirth Connect handles MLLP natively. You point it at a port, it listens, it parses, it ACKs. Done. One config screen, no custom code.
2. HL7 Parsing Is Messier Than It Looks
The pipe-delimited format looks simple:
PID|1||1234567^^^MRN||TEST^PATIENT^MARY^^MS||19920315|F|||14 SAMPLE ST^^SYDNEY^NSW^2000^AU
But consider:
-
Component separators:
PID-5isTEST^PATIENT^MARY^^MS, which is family, given, middle, suffix (empty), prefix. Miss the empty suffix and your prefix ends up as the suffix. -
Repeating fields:
PID-3can contain multiple identifiers separated by~. One might be the MRN, another the IHI (Individual Healthcare Identifier). You need to iterate and match by identifier type, not just grab the first one. -
Escape characters: A patient named
O'Brienmight appear asO\T\Brienin HL7 (where\T\is the subcomponent separator escape). Or it might not, depending on the sending system's configuration. -
Encoding characters: The first field of
MSH(MSH-1) is the field separator itself (|), andMSH-2defines the component, repetition, escape, and subcomponent separators. Different hospitals can (and do) use different separators.
In my Python script, I was doing segment.split('|')[5].split('^')[0] to get a family name. One unexpected empty field, one unexpected repeating group, and the whole positional mapping shifted silently. No error, just wrong data in the FHIR resource.
Mirth Connect's HL7 parser handles all of this natively. In a Mirth transformer, I write:
var familyName = msg['PID']['PID.5']['PID.5.1'].toString();
var givenName = msg['PID']['PID.5']['PID.5.2'].toString();
That's an E4X/XML path against a parsed HL7 tree, not string splitting. It handles repeating fields, component separators, and encoding characters correctly because that's what the parser is built for. Years of edge cases baked into a mature parser versus my three-day-old string splitter.
3. Message Routing Is a Separate Concern
A maternity workflow involves three message types:
| Message | Trigger | What It Creates |
|---|---|---|
ADT^A01 |
Patient admitted | Patient + Condition |
ORM^O01 |
Order placed | Encounter |
ORU^R01 |
Results available | Observation(s) |
In the "everything in Python" design, my MLLP listener would receive a raw HL7 message, I'd parse MSH-9 to determine the message type, then route to the right handler function. That's a message router, and I'd be building one from scratch.
Mirth Connect is literally a message router. You define a channel per message type (or one channel with a filter/router), point each destination at a different FastAPI endpoint, and Mirth handles the dispatch. The FastAPI endpoints receive clean, typed JSON payloads. They don't even need to know HL7 exists.
Mirth Channel:
Source: MLLP port 6661
Filter: msg['MSH']['MSH.9']['MSH.9.1'] + '^' + msg['MSH']['MSH.9']['MSH.9.2']
Destinations:
ADT^A01 -> POST http://fastapi:8000/fhir/Patient
ORM^O01 -> POST http://fastapi:8000/fhir/Encounter
ORU^R01 -> POST http://fastapi:8000/fhir/Observation/bundle
Each destination transformer extracts only the fields relevant to that message type and builds a flat JSON payload. FastAPI receives structured data with Pydantic validation on the input shape. Clean separation.
4. ACK/NACK Is Surprisingly Important
When a hospital system sends an HL7 message, it expects an acknowledgment. If it gets an ACK (MSA-1 = AA), it moves on. If it gets a NACK (MSA-1 = AE or AR), it may retry, queue the message, or alert an operator.
In my Python approach, I'd need to:
- Parse the incoming message
- Process it
- Build an HL7 ACK message with the correct
MSHfields mirrored back - Send it over the same TCP socket
- Handle the case where processing succeeds but the ACK fails to send
Mirth Connect auto-generates ACK messages. You configure whether to ACK on receipt (before processing) or after successful processing. You can customise the ACK content if needed. Another solved problem.
5. I Was Rebuilding an Integration Engine, Badly
Step back and look at what I was building in Python:
- A TCP/MLLP listener ✓
- An HL7 v2.5 parser ✓
- A message router ✓
- An ACK/NACK generator ✓
- Connection management and error handling ✓
That's an integration engine. Mirth Connect, Rhapsody, InterSystems HealthShare. These are products that teams of engineers have built and maintained for years. I was reimplementing one in a weekend, pretending it was "simpler" because it was in Python.
The honest assessment: I was spending 60-70% of my effort on plumbing (protocol handling, parsing, routing) and 30-40% on the actual value: the FHIR transformation and validation logic.
The Redesigned Architecture
Here's what I landed on:
Mirth Connect does what it's good at: protocol handling, HL7 parsing, message routing, ACK generation. It turns messy, protocol-specific HL7 into clean HTTP + JSON.
FastAPI does what it's good at: receiving typed payloads, applying business logic (like merging systolic + diastolic OBX segments into a single FHIR Blood Pressure panel), validating FHIR resources with Pydantic models from fhir.resources, and persisting to the FHIR server.
The boundary between them is a simple HTTP POST with a JSON body. FastAPI doesn't know about MLLP, segment separators, or ACK messages. Mirth doesn't know about FHIR profiles, conditional PUT, or Pydantic validation. Each component is testable in isolation.
Why Not Do Everything in Mirth?
Fair question. Plenty of teams build entire HL7-to-FHIR transformations inside Mirth Connect's JavaScript engine. You can construct FHIR JSON in a Mirth transformer and POST it directly to HAPI. Why add FastAPI at all?
Three reasons:
Type-Safe FHIR Validation
The fhir.resources library gives me Pydantic models for every FHIR R4 resource type. When I build a Patient resource, the model enforces:
-
gendermust be one ofmale,female,other,unknown, notForM -
nameis a list ofHumanNameobjects with specific structure -
identifierrequires bothsystemandvalue -
meta.profilemust be a list of valid URIs
If I typo Observation.status as "done" instead of "final", Pydantic raises a validation error with the exact field path before the resource ever reaches HAPI. In Mirth's JavaScript, I'd be constructing a plain JSON object with no schema enforcement. Typos become silent bugs that surface as cryptic HAPI 400 errors.
Testability
My FastAPI transformers have 153 unit tests and 20 integration tests at 90% coverage. Each transformer function takes a Pydantic input model and returns a FHIR resource. I can test edge cases (empty diagnoses arrays, missing middle names, orphan blood pressure readings) in milliseconds with pytest.
Testing Mirth channels is harder. You can write unit tests for Mirth's JavaScript functions, but testing the full channel behavior (source → filter → transformer → destination) requires either Mirth's built-in test tools or a running Mirth instance. The feedback loop is slower, and CI integration is less straightforward.
Python Ecosystem
For the transformation logic, Python gives me things Mirth's JavaScript doesn't:
-
fhir.resourcesfor FHIR R4 Pydantic models -
httpxfor async HTTP with connection pooling -
pydantic-settingsfor typed configuration from environment variables -
rufffor linting,mypy --strictfor type checking - The entire pytest ecosystem for testing
The FHIR mapping is where I add value. I want the best tools for that specific job.
The Tradeoff I Accepted
This architecture has a cost: an extra network hop. Every message goes from Mirth to FastAPI over HTTP, adding latency. In a high-throughput production system processing thousands of messages per second, this matters.
For a maternity ward generating maybe 50-100 messages per hour? The latency is invisible. And the benefits (clean separation, type safety, testability, better error handling) far outweigh the cost.
If throughput became a bottleneck, I'd look at gRPC between Mirth and FastAPI, or batch endpoints that accept multiple messages per request. But I'd still keep the separation. The architectural benefit is worth more than saving a few milliseconds.
What This Taught Me
Use the right tool for each layer. Integration engines exist because protocol handling and message parsing are genuinely hard, well-understood problems. Application frameworks exist because business logic, validation, and API design are different problems. Trying to solve both in one layer means solving neither well.
The boundary between components should be boring. HTTP + JSON between Mirth and FastAPI is boring. That's the point. The interesting work (the FHIR mapping, the BP panel merging, the AU Base profiling) happens inside the components, not at the boundaries.
"Simpler" isn't always fewer components. My initial one-component Python approach looked simpler. In practice, it was a tangled mess of protocol code, parsing code, and transformation code all in one place. Three components with clear responsibilities turned out to be simpler to build, test, and debug.
Try It Yourself
The full pipeline runs with one command:
docker compose up --build
Send a test HL7 message via MLLP:
python scripts/mllp_send.py samples/adt_a01_normal_delivery.hl7
Or skip Mirth entirely and hit FastAPI directly with JSON:
curl -s -X POST http://localhost:8000/fhir/Patient \
-H "Content-Type: application/json" \
-d '{ ... }'
Both paths produce the same validated, AU-profiled FHIR resources in HAPI.
Source code: github.com/budityw23/maternity-hl7-to-fhir-pipeline
This is the second article in my series on the Maternity HL7-to-FHIR Pipeline. The first article, Bridging Legacy Hospital Messages to Modern Healthcare APIs, covers the full architecture and design decisions. Next up: how I test the pipeline without a hospital.
Tags: #healthit #architecture #python #showdev
Top comments (0)