DEV Community

Cover image for Why I Put Mirth Connect in Front of FastAPI Instead of Parsing HL7 in Python
Budi Widhiyanto
Budi Widhiyanto

Posted on

Why I Put Mirth Connect in Front of FastAPI Instead of Parsing HL7 in Python

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
└─────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But consider:

  • Component separators: PID-5 is TEST^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-3 can 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'Brien might appear as O\T\Brien in 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 (|), and MSH-2 defines 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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Parse the incoming message
  2. Process it
  3. Build an HL7 ACK message with the correct MSH fields mirrored back
  4. Send it over the same TCP socket
  5. 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:

  • gender must be one of male, female, other, unknown, not F or M
  • name is a list of HumanName objects with specific structure
  • identifier requires both system and value
  • meta.profile must 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.resources for FHIR R4 Pydantic models
  • httpx for async HTTP with connection pooling
  • pydantic-settings for typed configuration from environment variables
  • ruff for linting, mypy --strict for 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
Enter fullscreen mode Exit fullscreen mode

Send a test HL7 message via MLLP:

python scripts/mllp_send.py samples/adt_a01_normal_delivery.hl7
Enter fullscreen mode Exit fullscreen mode

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 '{ ... }'
Enter fullscreen mode Exit fullscreen mode

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)