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
- .NET 6.0 or higher (or .NET Framework 4.5+)
- A .NET IDE or Editor such as Visual Studio IDE or Visual Studio Code
- NHAPI GitHub page is located here
- Install NHAPI NuGet Package using NuGet Package Manager
- You can find all the code demonstrated in this tutorial on GitHub here
“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:
| Character | Name | Hex Value | Description |
|---|---|---|---|
| SB | Start Block | 0x0B | Marks the beginning of a message |
| EB | End Block | 0x1C | Marks the end of a message |
| CR | Carriage Return | 0x0D | Follows 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
| Code | Meaning | Use Case |
|---|---|---|
| AA | Application Accept | Message processed successfully |
| AE | Application Error | Error processing message (retry may help) |
| AR | Application Reject | Message rejected (do not retry) |
| CA | Commit Accept | Enhanced mode: committed to safe storage |
| CE | Commit Error | Enhanced mode: commit failed |
| CR | Commit Reject | Enhanced 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!