DEV Community

Beck_Moulton
Beck_Moulton

Posted on

Surviving the HL7 Nightmare: Strategies for Decoupling Modern SaaS from Legacy Hospital Systems

If you’re a modern developer, your world probably revolves around REST, GraphQL, JSON, and maybe a little gRPC if you're feeling fancy. Then, one day, you land a contract with a hospital or a healthcare startup. You think, "Great! I'll just hit their API to get patient admission data."

Then they send you the specs.

It’s not JSON. It’s not XML. It’s a string of pipe-delimited text that looks like a cat walked across a keyboard, transmitted over a raw TCP socket that never closes. Welcome to HL7 v2, the 30-year-old standard that still runs the world's healthcare infrastructure.

For a modern SaaS engineer, integrating with this can feel like a nightmare. But if you let that legacy mess leak into your clean, modern codebase, you’re creating technical debt that will haunt you for years.

The solution? The Anti-Corruption Layer (ACL).

In this post, we’ll explore how to use the ACL pattern to survive the HL7 interface without losing your sanity (or your clean architecture).

The Monster: HL7 v2 and MLLP

Before we fight the monster, we have to understand it. HL7 v2 messages look like this:

MSH|^~\&|EPIC|HOSPITAL|MYAPP|SaaS|202501011230||ADT^A01|MSG00001|P|2.3
PID|1||12345^^^MRN||DOE^JOHN^^^^||19800101|M
PV1|1|I|200^Bed1^Room2||||1234^Doctor^Smith
Enter fullscreen mode Exit fullscreen mode

To make matters worse, these messages aren't sent via HTTP. They use MLLP (Minimum Lower Layer Protocol). This means you have to open a raw TCP socket, listen for a specific start byte (0x0B), read until end bytes (0x1C 0x0D), and send back an acknowledgement (ACK) immediately, or the hospital system throws a fit.

If you write code that parses PID|1||... directly inside your main business logic controller, you have successfully corrupted your domain model.

The Strategy: Anti-Corruption Layer (ACL)

The Anti-Corruption Layer is a pattern from Domain-Driven Design (DDD). It creates a defensive boundary between your downstream system (the hospital) and your upstream system (your shiny SaaS).

Your internal system should never know what an HL7 message looks like. It should only speak your clean, internal domain language.

Components of an HL7 ACL

  1. The Facade (Ingestion): Handles the ugly MLLP socket connection.
  2. The Adapter (Parsing): Turns the pipe-delimited text into a usable object.
  3. The Translator (Mapping): The most important part. Converts the HL7 object into your Domain Model.

Let's Build It (Node.js Example)

Let's say we want to create a PatientAdmission event in our system whenever a hospital sends an ADT^A01 (Admit) message.

We'll use node-hl7-client or a similar lightweight parser (conceptually) to handle the low-level string splitting, but the architecture is what matters here.

Step 1: Define Your Clean Domain Model

First, ignore the hospital. What does your app need?

// domain/models.ts
// This is pure. No HL7 junk here.
interface PatientAdmission {
  patientId: string;
  fullName: string;
  admittedAt: Date;
  location: string;
  attendingPhysician: string;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: The Corruption (HL7 Input)

This is what we receive. It's ugly.

const rawHL7 = "MSH|^~\\&|...|ADT^A01|...\rPID|1||12345^^^MRN||DOE^JOHN...\rPV1|..."
Enter fullscreen mode Exit fullscreen mode

Step 3: The Translator Service

This is where the magic happens. This function is the only place in your entire codebase allowed to know about "PID-5" or "PV1-3".

// services/AntiCorruptionLayer.ts
import { parse } from 'some-hl7-library';
import { PatientAdmission } from '../domain/models';

export class HL7ToDomainTranslator {

  static translateAdmission(rawMessage: string): PatientAdmission {
    const hl7 = parse(rawMessage);

    // Check if message type is actually Admission (ADT A01)
    if (hl7.get('MSH.9.1') !== 'ADT' || hl7.get('MSH.9.2') !== 'A01') {
       throw new Error('Message is not an admission event');
    }

    // MAP legacy fields to Modern Domain
    // Notice how we encapsulate the ugly indexing here?
    return {
      patientId: hl7.get('PID.3.1').toString(), // MRN
      // HL7 uses Carat delimiters for names: Family^Given
      fullName: `${hl7.get('PID.5.2')} ${hl7.get('PID.5.1')}`, 
      admittedAt: this.parseHL7Date(hl7.get('MSH.7').toString()),
      location: hl7.get('PV1.3.1').toString(), // Nursing Unit
      attendingPhysician: `${hl7.get('PV1.7.2')} ${hl7.get('PV1.7.1')}`
    };
  }

  // Helper to deal with HL7's weird YYYYMMDDHHMM formats
  private static parseHL7Date(dateString: string): Date {
    // ... specialized date parsing logic
    return new Date(); // Simplified for demo
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: The Facade (Infrastructure Layer)

Now we wire it up. Your main application logic just receives a clean PatientAdmission object.

// infrastructure/TcpServer.ts
import net from 'net';
import { HL7ToDomainTranslator } from '../services/AntiCorruptionLayer';
import { admissionController } from '../controllers/admissionController';

const server = net.createServer((socket) => {
  socket.on('data', (data) => {
    try {
      // 1. Ingest (Strip MLLP framing characters)
      const rawMessage = stripMLLP(data.toString());

      // 2. Translate (The ACL at work!)
      const cleanEvent = HL7ToDomainTranslator.translateAdmission(rawMessage);

      // 3. Hand off to modern business logic
      console.log("New clean event:", cleanEvent);
      admissionController.handleNewAdmission(cleanEvent);

      // 4. Acknowledge (Required by HL7)
      socket.write(createAck(rawMessage));

    } catch (err) {
      console.error("Failed to process HL7", err);
      // Send NACK (Negative Ack) logic here
    }
  });
});

server.listen(5000, () => console.log('Listening for Hospital Data...'));
Enter fullscreen mode Exit fullscreen mode

Why This Wins

  1. Decoupling: If the hospital switches from HL7 v2 to FHIR next year, you only rewrite the Translator class. Your admissionController and PatientAdmission model don't change.
  2. Testability: You can unit test the Translator with sample text files without needing a live TCP connection.
  3. Sanity: Your core business logic isn't littered with split('^') and PID.5 references.

A Note on "Buy vs. Build"

While the code above is fun to write, handling MLLP sockets in production is tricky (timeouts, buffer fragmentation, VPN tunnels).

In a real enterprise scenario, you might treat the Infrastructure part of your ACL as a bought service. Tools like Mirth Connect (NextGen Connect) or cloud offerings like AWS HealthLake or Google Cloud Healthcare API can act as your physical ACL. They ingest the raw MLLP and push clean JSON to your HTTP webhook.

However, even if you receive JSON, you still need a logical ACL to map their schema to yours. Never blindly trust an external schema!

Wrapping Up

Integrating with legacy hospital systems is a rite of passage for health-tech developers. It’s messy, but with a solid Anti-Corruption Layer, it doesn't have to be destructive. Isolate the mess, translate it once, and keep your domain clean.

I wrote more about specific engineering patterns and integration strategies in my personal blog, so feel free to check out more tech guides if you're tackling similar architectural challenges.

Happy coding, and may your socket connections always stay open!

Top comments (0)