HL7 Programming using .NET and NHAPI - Receiving HL7 Messages

Introduction

This is part of my HL7 article series. In my earlier tutorial titled "HL7 Programming using .NET and NHAPI - Sending HL7 Messages", we learned how to transmit HL7 messages to a remote system using MLLP. In this tutorial, we will build the receiving side - an HL7 server that listens for incoming connections, processes messages, and returns appropriate acknowledgments.

Building a robust HL7 receiver is essential for integrating with hospital information systems, laboratory systems, radiology systems, and other healthcare applications that exchange data using the HL7 V2.x standard.

Tools and Resources Needed

“The only way to do great work is to love what you do.” ~ Steve Jobs

Understanding MLLP

Before diving into the code, let's review the Minimum Lower Layer Protocol (MLLP) that wraps HL7 messages during TCP/IP transmission. MLLP uses special framing characters to mark the start and end of each message:

CharacterNameHex ValueDescription
SBStart Block0x0BMarks the beginning of a message
EBEnd Block0x1CMarks the end of a message
CRCarriage Return0x0DFollows the End Block

A complete MLLP frame looks like: <SB>HL7 Message Content<EB><CR>

HL7 Server Architecture

Our HL7 server implementation consists of several components:

  • Hl7Server: TCP listener that handles MLLP connections
  • MessageRouter: Determines message type and routes to handlers
  • IHl7MessageHandler: Interface for message-specific handlers
  • AckGenerator: Creates acknowledgment responses
  • Specific Handlers: ADT, ORM, ORU, and generic handlers

The Message Handler Interface

First, let's define an interface that all message handlers will implement:

namespace Com.SaravananSubramanian.Nhapi.Hl7Server;

/// <summary>
/// Interface for HL7 message handlers.
/// Implement this interface to create custom handlers for specific message types.
/// </summary>
public interface IHl7MessageHandler
{
    /// <summary>
    /// Processes an incoming HL7 message and returns the response (typically an ACK).
    /// </summary>
    /// <param name="rawMessage">The raw HL7 message string</param>
    /// <returns>The response message (ACK or NAK)</returns>
    string HandleMessage(string rawMessage);
}

The Message Router

The MessageRouter extracts the message type from MSH-9 to determine which handler should process each message:

using NHapi.Base.Parser;

namespace Com.SaravananSubramanian.Nhapi.Hl7Server;

/// <summary>
/// Routes HL7 messages to appropriate handlers based on message type.
/// </summary>
public class MessageRouter
{
    private readonly PipeParser _parser = new();

    /// <summary>
    /// Extracts the message type from an HL7 message (MSH-9-1).
    /// </summary>
    public string GetMessageType(string rawMessage)
    {
        try
        {
            var message = _parser.Parse(rawMessage);
            var terser = new NHapi.Base.Util.Terser(message);
            return terser.Get("MSH-9-1") ?? "UNKNOWN";
        }
        catch
        {
            // Fallback: Parse manually from raw message
            return ParseMessageTypeManually(rawMessage);
        }
    }

    /// <summary>
    /// Extracts the trigger event from an HL7 message (MSH-9-2).
    /// </summary>
    public string GetTriggerEvent(string rawMessage)
    {
        try
        {
            var message = _parser.Parse(rawMessage);
            var terser = new NHapi.Base.Util.Terser(message);
            return terser.Get("MSH-9-2") ?? "UNKNOWN";
        }
        catch
        {
            return ParseTriggerEventManually(rawMessage);
        }
    }

    /// <summary>
    /// Gets the full message type string (e.g., "ADT^A01").
    /// </summary>
    public string GetFullMessageType(string rawMessage)
    {
        return $"{GetMessageType(rawMessage)}^{GetTriggerEvent(rawMessage)}";
    }

    private static string ParseMessageTypeManually(string rawMessage)
    {
        var lines = rawMessage.Split('\r');
        var mshLine = lines.FirstOrDefault(l => l.StartsWith("MSH"));
        if (mshLine == null) return "UNKNOWN";

        var fields = mshLine.Split('|');
        if (fields.Length < 9) return "UNKNOWN";

        var messageType = fields[8].Split('^');
        return messageType.Length > 0 ? messageType[0] : "UNKNOWN";
    }

    private static string ParseTriggerEventManually(string rawMessage)
    {
        var lines = rawMessage.Split('\r');
        var mshLine = lines.FirstOrDefault(l => l.StartsWith("MSH"));
        if (mshLine == null) return "UNKNOWN";

        var fields = mshLine.Split('|');
        if (fields.Length < 9) return "UNKNOWN";

        var messageType = fields[8].Split('^');
        return messageType.Length > 1 ? messageType[1] : "UNKNOWN";
    }
}

ACK Message Generator

The AckGenerator creates proper acknowledgment messages using NHAPI:

using System.Globalization;
using NHapi.Base.Model;
using NHapi.Base.Parser;
using NHapi.Model.V23.Message;
using NHapi.Model.V23.Segment;

namespace Com.SaravananSubramanian.Nhapi.Hl7Server;

/// <summary>
/// Utility class for generating HL7 ACK (Acknowledgment) messages.
/// </summary>
public static class AckGenerator
{
    private static readonly PipeParser Parser = new();

    /// <summary>
    /// Creates a positive acknowledgment (AA) for the given message.
    /// </summary>
    public static string CreateAck(string originalMessage, string? textMessage = null)
    {
        return CreateAckInternal(originalMessage, "AA", textMessage ?? "Message accepted");
    }

    /// <summary>
    /// Creates a negative acknowledgment (Application Error).
    /// </summary>
    public static string CreateNack(string originalMessage, string ackCode, string errorMessage)
    {
        return CreateAckInternal(originalMessage, ackCode, errorMessage);
    }

    private static string CreateAckInternal(string originalMessage, string ackCode, string textMessage)
    {
        try
        {
            var parsedOriginal = Parser.Parse(originalMessage);
            var ack = new ACK();

            BuildMshSegment(ack.MSH, parsedOriginal);
            BuildMsaSegment(ack.MSA, parsedOriginal, ackCode, textMessage);

            return Parser.Encode(ack);
        }
        catch (Exception ex)
        {
            return CreateMinimalAck(originalMessage, ackCode, textMessage, ex.Message);
        }
    }

    private static void BuildMshSegment(MSH msh, IMessage originalMessage)
    {
        var originalMsh = (MSH)originalMessage.GetStructure("MSH");

        msh.FieldSeparator.Value = "|";
        msh.EncodingCharacters.Value = "^~\\&";

        // Swap sender/receiver
        msh.SendingApplication.NamespaceID.Value =
            originalMsh.ReceivingApplication.NamespaceID.Value ?? "SERVER";
        msh.SendingFacility.NamespaceID.Value =
            originalMsh.ReceivingFacility.NamespaceID.Value ?? "FACILITY";
        msh.ReceivingApplication.NamespaceID.Value =
            originalMsh.SendingApplication.NamespaceID.Value ?? "CLIENT";
        msh.ReceivingFacility.NamespaceID.Value =
            originalMsh.SendingFacility.NamespaceID.Value ?? "FACILITY";

        msh.DateTimeOfMessage.TimeOfAnEvent.Value =
            DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);

        msh.MessageType.MessageType.Value = "ACK";
        msh.MessageType.TriggerEvent.Value =
            originalMsh.MessageType.TriggerEvent.Value ?? "";

        msh.MessageControlID.Value = $"ACK{DateTime.Now:yyyyMMddHHmmssfff}";
        msh.ProcessingID.ProcessingID.Value =
            originalMsh.ProcessingID.ProcessingID.Value ?? "P";
        msh.VersionID.Value = originalMsh.VersionID.Value ?? "2.3";
    }

    private static void BuildMsaSegment(MSA msa, IMessage originalMessage, string ackCode, string textMessage)
    {
        var originalMsh = (MSH)originalMessage.GetStructure("MSH");

        msa.AcknowledgementCode.Value = ackCode;
        msa.MessageControlID.Value = originalMsh.MessageControlID.Value ?? "";
        msa.TextMessage.Value = textMessage;
    }

    private static string CreateMinimalAck(string originalMessage, string ackCode,
        string textMessage, string error)
    {
        var controlId = ExtractControlId(originalMessage);
        var timestamp = DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);

        return $"MSH|^~\\&|SERVER|FACILITY|CLIENT|FACILITY|{timestamp}||ACK|ACK{timestamp}|P|2.3\r" +
               $"MSA|{ackCode}|{controlId}|{textMessage}";
    }

    private static string ExtractControlId(string message)
    {
        try
        {
            var lines = message.Split('\r');
            var msh = lines.FirstOrDefault(l => l.StartsWith("MSH"));
            if (msh != null)
            {
                var fields = msh.Split('|');
                if (fields.Length > 9) return fields[9];
            }
        }
        catch { }
        return "UNKNOWN";
    }
}

ADT Message Handler Example

Here's an example handler for ADT (Admission, Discharge, Transfer) messages:

using NHapi.Base.Parser;
using NHapi.Base.Util;

namespace Com.SaravananSubramanian.Nhapi.Hl7Server;

/// <summary>
/// Handler for ADT (Admission, Discharge, Transfer) messages.
/// </summary>
public class AdtMessageHandler : IHl7MessageHandler
{
    private readonly PipeParser _parser = new();
    private readonly MessageRouter _router = new();

    public string HandleMessage(string rawMessage)
    {
        try
        {
            var triggerEvent = _router.GetTriggerEvent(rawMessage);
            var message = _parser.Parse(rawMessage);
            var terser = new Terser(message);

            // Extract patient information
            var patientId = terser.Get("PID-3-1") ?? "Unknown";
            var patientName = $"{terser.Get("PID-5-1")}, {terser.Get("PID-5-2")}";

            Console.WriteLine($"  ADT {triggerEvent} - Patient: {patientName} (ID: {patientId})");

            // Process based on trigger event
            var result = triggerEvent switch
            {
                "A01" => ProcessAdmit(terser),
                "A02" => ProcessTransfer(terser),
                "A03" => ProcessDischarge(terser),
                "A04" => ProcessRegister(terser),
                "A08" => ProcessUpdate(terser),
                _ => ProcessGenericAdt(triggerEvent, terser)
            };

            return AckGenerator.CreateAck(rawMessage, result);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"  Error processing ADT message: {ex.Message}");
            return AckGenerator.CreateNack(rawMessage, "AE", $"Error: {ex.Message}");
        }
    }

    private string ProcessAdmit(Terser terser)
    {
        var location = terser.Get("PV1-3-1") ?? "Unknown";
        Console.WriteLine($"    -> Admit to location: {location}");
        // In production: validate data, update database, trigger workflows
        return "Patient admitted successfully";
    }

    private string ProcessTransfer(Terser terser)
    {
        var priorLocation = terser.Get("PV1-6-1") ?? "Unknown";
        var newLocation = terser.Get("PV1-3-1") ?? "Unknown";
        Console.WriteLine($"    -> Transfer from: {priorLocation} to: {newLocation}");
        return "Patient transferred successfully";
    }

    private string ProcessDischarge(Terser terser)
    {
        var dischargeDate = terser.Get("PV1-45-1") ?? "Unknown";
        Console.WriteLine($"    -> Discharge date: {dischargeDate}");
        return "Patient discharged successfully";
    }

    private string ProcessRegister(Terser terser)
    {
        var visitNumber = terser.Get("PV1-19-1") ?? "Unknown";
        Console.WriteLine($"    -> Registration, Visit #: {visitNumber}");
        return "Patient registered successfully";
    }

    private string ProcessUpdate(Terser terser)
    {
        Console.WriteLine($"    -> Patient information updated");
        return "Patient information updated successfully";
    }

    private string ProcessGenericAdt(string triggerEvent, Terser terser)
    {
        Console.WriteLine($"    -> Generic ADT processing for {triggerEvent}");
        return $"ADT {triggerEvent} processed successfully";
    }
}

The Main HL7 Server

Finally, here's the main server class that ties everything together:

using System.Net;
using System.Net.Sockets;
using System.Text;
using NHapi.Base.Parser;

namespace Com.SaravananSubramanian.Nhapi.Hl7Server;

public class Program
{
    private const int DefaultPort = 2575; // Standard HL7 port

    public static void Main(string[] args)
    {
        var port = args.Length > 0 && int.TryParse(args[0], out var p) ? p : DefaultPort;

        Console.WriteLine("==============================================");
        Console.WriteLine("  NHAPI HL7 V2 Server");
        Console.WriteLine("==============================================");
        Console.WriteLine();

        var server = new Hl7Server(port);

        using var cts = new CancellationTokenSource();
        Console.CancelKeyPress += (_, e) =>
        {
            e.Cancel = true;
            cts.Cancel();
        };

        var serverTask = server.StartAsync(cts.Token);

        Console.WriteLine($"Server listening on port {port}");
        Console.WriteLine("Press Ctrl+C or Enter to stop the server...");

        Console.ReadLine();
        cts.Cancel();

        Console.WriteLine("\nStopping server...");
        try { serverTask.Wait(TimeSpan.FromSeconds(5)); }
        catch (AggregateException) { }
        Console.WriteLine("Server stopped.");
    }
}

public class Hl7Server
{
    private readonly int _port;
    private readonly Dictionary<string, IHl7MessageHandler> _handlers = new();
    private readonly MessageRouter _router = new();
    private readonly PipeParser _parser = new();
    private TcpListener? _listener;

    // MLLP framing characters
    private const char StartBlock = (char)0x0B;
    private const char EndBlock = (char)0x1C;
    private const char CarriageReturn = (char)0x0D;

    public Hl7Server(int port)
    {
        _port = port;
        RegisterDefaultHandlers();
    }

    private void RegisterDefaultHandlers()
    {
        RegisterHandler("ADT", new AdtMessageHandler());
        RegisterHandler("ORM", new OrmMessageHandler());
        RegisterHandler("ORU", new OruMessageHandler());
        RegisterHandler("*", new GenericMessageHandler()); // Fallback

        Console.WriteLine("Registered message handlers:");
        foreach (var handler in _handlers)
        {
            Console.WriteLine($"  - {handler.Key}: {handler.Value.GetType().Name}");
        }
    }

    public void RegisterHandler(string messageType, IHl7MessageHandler handler)
    {
        _handlers[messageType.ToUpperInvariant()] = handler;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _listener = new TcpListener(IPAddress.Any, _port);
        _listener.Start();

        try
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                var client = await _listener.AcceptTcpClientAsync(cancellationToken);
                _ = HandleClientAsync(client, cancellationToken);
            }
        }
        catch (OperationCanceledException) { }
        finally
        {
            _listener.Stop();
        }
    }

    private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
    {
        using (client)
        {
            var endpoint = client.Client.RemoteEndPoint?.ToString() ?? "unknown";
            Console.WriteLine($"\n[Connection] Client connected from {endpoint}");

            try
            {
                await using var stream = client.GetStream();
            var buffer = new byte[8192];
            var messageBuffer = new StringBuilder();

            while (!cancellationToken.IsCancellationRequested && client.Connected)
            {
                var bytesRead = await stream.ReadAsync(buffer, cancellationToken);
                if (bytesRead == 0) break;

                messageBuffer.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead));

                // Process complete MLLP frames
                var data = messageBuffer.ToString();
                var startIndex = data.IndexOf(StartBlock);

                while (startIndex >= 0)
                {
                    var endIndex = data.IndexOf(EndBlock, startIndex);
                    if (endIndex < 0) break;

                    // Extract HL7 message
                    var hl7Message = data.Substring(startIndex + 1, endIndex - startIndex - 1);

                    // Process and get response
                    var response = ProcessMessage(hl7Message);

                    // Send MLLP-framed response
                    var responseBytes = Encoding.UTF8.GetBytes(
                        $"{StartBlock}{response}{EndBlock}{CarriageReturn}");
                    await stream.WriteAsync(responseBytes, cancellationToken);

                    data = data[(endIndex + 2)..];
                    startIndex = data.IndexOf(StartBlock);
                }

                messageBuffer.Clear();
                messageBuffer.Append(data);
            }
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                Console.WriteLine($"[Connection] Error: {ex.Message}");
            }
            finally
            {
                Console.WriteLine($"[Connection] Client disconnected: {endpoint}");
            }
        }
    }

    private string ProcessMessage(string rawMessage)
    {
        var timestamp = DateTime.Now.ToString("HH:mm:ss");
        Console.WriteLine($"\n[{timestamp}] Message received:");

        try
        {
            var messageType = _router.GetMessageType(rawMessage);
            var handler = GetHandler(messageType);

            Console.WriteLine($"  Message Type: {_router.GetFullMessageType(rawMessage)}");
            Console.WriteLine($"  Handler: {handler.GetType().Name}");

            return handler.HandleMessage(rawMessage);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"  Error: {ex.Message}");
            return AckGenerator.CreateNack(rawMessage, "AE", $"Error: {ex.Message}");
        }
    }

    private IHl7MessageHandler GetHandler(string messageType)
    {
        if (_handlers.TryGetValue(messageType.ToUpperInvariant(), out var handler))
            return handler;

        if (_handlers.TryGetValue("*", out var fallback))
            return fallback;

        throw new InvalidOperationException($"No handler for: {messageType}");
    }
}

Running the Server

To run the server, simply execute the application:

dotnet run

==============================================
  NHAPI HL7 V2 Server
==============================================

Registered message handlers:
  - ADT: AdtMessageHandler
  - ORM: OrmMessageHandler
  - ORU: OruMessageHandler
  - *: GenericMessageHandler

Server listening on port 2575
Press Ctrl+C or Enter to stop the server...
--------------------------------------------------

You can test the server using the HAPI Test Panel, your sending application from the previous tutorial, or any MLLP client.

Acknowledgment Codes

CodeMeaningUse Case
AAApplication AcceptMessage processed successfully
AEApplication ErrorError processing message (retry may help)
ARApplication RejectMessage rejected (do not retry)
CACommit AcceptEnhanced mode: committed to safe storage
CECommit ErrorEnhanced mode: commit failed
CRCommit RejectEnhanced mode: rejected

Conclusion

In this tutorial, we built a complete HL7 receiving server using .NET and NHAPI. The server demonstrates MLLP protocol handling, message routing based on type, and proper acknowledgment generation. The modular architecture with separate handlers for different message types makes it easy to extend for your specific integration needs.

In production environments, you would add features like logging, database persistence, error queuing, and monitoring. The handler pattern shown here provides a clean foundation for building robust healthcare integrations. In the next tutorial in this series, we will dive deeper into HL7 acknowledgment messages and how to properly generate and handle them. See you then!