Skip to content
Art2link ESB HomeDocumentationBlogContact
Updated May 5, 2026
Tutorials/Healthcare & EDI/HL7 ADT patient feed

HL7 ADT patient feed

A hospital’s main patient-record system is constantly noting when patients are admitted, moved between wards, and discharged. It writes those events out in a clinical format called HL7, bundling a few minutes’ worth at a time into a file and dropping each file in a shared folder. Somewhere else in the hospital is the master patient list, the single roster everyone trusts for who is where. Its job is to stay current. Every few minutes the new file is collected, each event in it is read, and the master list is brought up to date. Two things can go wrong and must be handled with care. One bad record in a file must never stop the good ones around it from being processed, and because files can arrive slightly out of order, a stale event must never overwrite newer information about the same patient.

The parties are the hospital’s patient-record system, which produces the events, and the master patient list, which has to reflect them. Between the two sits the flow: every few minutes it picks up the latest file, reads each admission, transfer, or discharge inside it, and updates the matching patient on the list. The picture below is the whole story in plain terms.

Keep the master patient list current Patient-record system admit, transfer, discharge Shared folder a file every few minutes Read each event in the file Master patient list brought up to date One bad record set aside, others flow on

Here is how Art2link ESB builds that. Inside the product, work moves as small messages across a shared message backbone called the bus, and a flow is wired from a few simple parts: a receive port brings data in, a send port hands data out, and an adapter is the connector a port uses to reach a particular kind of system. A Scheduler (a clock that wakes the flow on a timer) ticks every five minutes and drives a two-way SFTP Caller port that downloads the newest file from the hospital’s export folder.

HL7 files are written in a clinical shorthand that nothing else on the bus understands, so they have to be translated the moment they arrive, exactly as the inbound EDI flow does. That translation is done by a pipeline component, a small piece of custom code that runs on a port to reshape a message as it passes through. The disassembler component here, named Hl7AdtDisassembler, breaks the file into one event at a time, reads what kind of event it is and the patient details it carries, and rewrites each one as a clean, ordinary record, a PatientEvent. A SQL Caller send port then updates the master list; because the component translated at the edge, the database never has to deal with the raw clinical format. The port that does the updating picks up only these records, through a standing instruction called a subscription:

EXPRESSION
{{Message.MessageType}} == "PatientEvent"

The disassembler also lifts the event code into a named label called a promoted token, a value pulled to the surface of a message so other ports can route on it without reading the body. It is there for the day other consumers appear: bed management subscribing on {{Promoted.PatientEvent.EventCode}} == "A02", a discharge-letter flow on "A03", new ports, no new feed, the same fan-out economics as everywhere else on the bus.

HL7 batch (SFTP fetch) MSH|^~\&|…A01 MSH|^~\&|…A03 MSH|^~\&|…A02 ADT disassembler split on MSH, read PID BUS PatientEvent ×N SQL Caller MPI upsert MPI DB future: EventCode subscribers (A02, A03…)

When it fails. ADT volume is relentless, so per-message isolation matters more here than anywhere: one malformed PID costs exactly one message, the disassembler parses each message in its own try/catch and publishes the failure under the shared exception type AdtPostFailed (an upsert the index rejects lands there too, through the ports’ exception handling), O365 Mail alert, replay from tracking, while hundreds of others flow on. A batch file the component cannot read at all suspends whole as a poison message under Activity Notifications. This is PHI like the 835 flow: host-key-pinned Authentication, evidence in the logs, and no patient names in alert emails, the MRN is enough for operations.

EXPRESSION
{{Message.MessageType}} == "AdtPostFailed"
Order matters per patient, not per feed. An A03 discharge processed before its A01 admit leaves the index lying. Carry MSH-7’s timestamp into the canonical event and let the upsert procedure ignore anything older than what it already holds, last-write-wins by event time, not arrival time, makes ordering hiccups self-healing.

Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.

Before you start. Every artifact below, ports, message types, the disassembler, lives inside one Application. Under Applications, create it first, Name and Namespace GenHosp, both code-safe, no dots or special characters, and select it so the steps build into it.
1
Step One
Create the message types
The two types

Under the application’s Message types, create the two types this flow routes on. A message type is a name plus a format, no schema required:

NameFormatPurpose
PatientEventJSONone canonical event per HL7 message, as the disassembler (Step 4) publishes it
AdtPostFailedJSONthe exception type the failure path publishes under (Step 8)

Promotions live on the message type, so add them while you are here. The upsert port’s parameters (Step 7) and the alert subject (Step 8) bind these values, and adapter parameters are plain strings: they bind {{…}} tokens but never evaluate a body path.

Promotions on PatientEvent
PromotionPath
EventCode$.eventCode
Mrn$.mrn
Family$.patient.family
Given$.patient.given
Dob$.patient.dob
Sex$.patient.sex
Ward$.location.ward
Room$.location.room
Bed$.location.bed
EventTime$.eventTime
Promotion on AdtPostFailed

A failed event re-publishes payload intact, but promotions are type-qualified, so the exception type needs its own:

PromotionPath
Mrn$.mrn

2
Step Two
Create the Authentications

Three external parties, three credentials. Under the application’s Authentications, the dialog asks for a Name, the Adapter it pairs with, and a Definition, the credential shape that adapter offers, with the Application preset; the Definition decides the config section that follows.

HIS export account, for the SFTP Caller
SettingValue
NameHisSftp
AdapterSFTP Caller
DefinitionSFTP Password Authentication
Username / Passwordthe export account credentials
Host Key Fingerprintthe HIS server’s pinned host key
MPI database, for the SQL Caller
SettingValue
NameMpiDbSql
AdapterSQL Caller
DefinitionSQL Server Connection
Connection String (Database Config)the master patient index database’s connection string, credentials included
Alert mailbox, for O365 Mail
SettingValue
NameO365OpsMail
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)an app registration with Mail.Send granted

3
Step Three
Create the database objects

Create the index table and its upsert in the MPI database; the upsert port (Step 7) calls the procedure. dbo.UpsertPatient applies an event only when its timestamp is at least as new as what the index already holds, last-write-wins by event time, so an out-of-order or replayed message is self-healing.

The index table and the upsert procedure
SQL
CREATE TABLE dbo.MasterPatientIndex (
    Mrn           VARCHAR(40)   NOT NULL PRIMARY KEY,
    Family        NVARCHAR(100) NULL,
    Given         NVARCHAR(100) NULL,
    Dob           DATE          NULL,
    Sex           CHAR(1)       NULL,
    Ward          VARCHAR(10)   NULL,
    Room          VARCHAR(10)   NULL,
    Bed           VARCHAR(10)   NULL,
    LastEventCode VARCHAR(8)    NULL,
    LastEventTime DATETIME2     NULL
);

CREATE OR ALTER PROCEDURE dbo.UpsertPatient
    @Mrn VARCHAR(40), @Family NVARCHAR(100), @Given NVARCHAR(100),
    @Dob DATE, @Sex CHAR(1), @Ward VARCHAR(10), @Room VARCHAR(10), @Bed VARCHAR(10),
    @EventCode VARCHAR(8), @EventTime DATETIME2
AS
BEGIN
    SET NOCOUNT ON;
    MERGE dbo.MasterPatientIndex AS tgt
    USING (SELECT @Mrn AS Mrn) AS src ON tgt.Mrn = src.Mrn
    WHEN MATCHED AND @EventTime >= ISNULL(tgt.LastEventTime, '0001-01-01') THEN
        UPDATE SET Family = @Family, Given = @Given, Dob = @Dob, Sex = @Sex,
                   Ward = @Ward, Room = @Room, Bed = @Bed,
                   LastEventCode = @EventCode, LastEventTime = @EventTime
    WHEN NOT MATCHED THEN
        INSERT (Mrn, Family, Given, Dob, Sex, Ward, Room, Bed, LastEventCode, LastEventTime)
        VALUES (@Mrn, @Family, @Given, @Dob, @Sex, @Ward, @Room, @Bed, @EventCode, @EventTime);
END;

4
Step Four
Build the ADT disassembler
Hl7AdtDisassembler

In Pipeline components, create Hl7AdtDisassembler via the AI Accelerator’s guided mode: inbound shape is the batch below, the boundary is each MSH segment, the output one PatientEvent JSON per message (Message Type set on the outgoing message; event code from MSH-9, demographics from PID, event time from MSH-7). Expose the field separator and encoding characters as component properties, HIS exports vary, and specify that unknown event codes pass through with the code intact rather than being dropped.

The sample batch
HL7
MSH|^~\&|HIS|GENHOSP|A2L|INTEG|20260605071201||ADT^A01|MSG-90412|P|2.5
EVN|A01|20260605071200
PID|1||MRN-104872^^^GENHOSP||DOE^JANE||19840312|F
PV1|1|I|W3^301^B|||||||MED
One PatientEvent per message
JSON
{
  "eventCode": "A01",
  "eventTime": "2026-06-05T07:12:00",
  "mrn": "MRN-104872",
  "patient": { "family": "DOE", "given": "JANE", "dob": "1984-03-12", "sex": "F" },
  "location": { "ward": "W3", "room": "301", "bed": "B" },
  "messageId": "MSG-90412"
}
The component code

Each message parses inside its own try/catch: a malformed PID is published as one AdtPostFailed, message id and error only, no patient data, and the loop moves on; only a batch the component cannot read at all returns Success = false and suspends whole.

C#
using System.Text.Json;
using CC.Art2link.Pipelines.Domain.Models.PipelineComponents;

public sealed class Hl7Config
{
    public string FieldSeparator     { get; set; } = "|";
    public string ComponentSeparator { get; set; } = "^";
}

public sealed class Hl7AdtDisassembler : PipelineComponentBase<Hl7Config>
{
    public override string Name => "Hl7AdtDisassembler";

    protected override Task<PipelineComponentOutput> ExecuteAsync(
        PipelineComponentInput input,
        Hl7Config config,
        CancellationToken cancellationToken)
    {
        try
        {
            var fs = config.FieldSeparator[0];
            var cs = config.ComponentSeparator[0];

            var segs = input.Body
                .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);

            var messages = new List<PipelineMessage>();
            List<string>? msg = null;

            void Flush()
            {
                if (msg is null) return;
                try
                {
                    messages.Add(BuildEvent(msg, fs, cs));
                }
                catch (Exception ex)
                {
                    // One bad message costs one message, never the batch.
                    messages.Add(BuildParseFailure(msg, fs, ex));
                }
            }

            foreach (var line in segs)
            {
                if (line.StartsWith("MSH")) { Flush(); msg = new List<string>(); }
                msg?.Add(line);
            }
            Flush();

            return Task.FromResult(new PipelineComponentOutput
            {
                Success = true, Messages = messages
            });
        }
        catch (Exception ex)
        {
            return Task.FromResult(new PipelineComponentOutput
            {
                Success      = false,
                ErrorMessage = $"Hl7AdtDisassembler: {ex.Message}",
                Exception    = ex
            });
        }
    }

    private static PipelineMessage BuildEvent(List<string> segs, char fs, char cs)
    {
        var msh = segs[0].Split(fs);
        var pid = segs.FirstOrDefault(s => s.StartsWith("PID"))?.Split(fs);
        var pv1 = segs.FirstOrDefault(s => s.StartsWith("PV1"))?.Split(fs);

        var evParts   = msh[8].Split(cs);              // ADT^A01
        var eventCode = evParts.Length > 1 ? evParts[1] : msh[8];
        var name = pid is not null && pid.Length > 5 ? pid[5].Split(cs) : Array.Empty<string>();
        var loc  = pv1 is not null && pv1.Length > 3 ? pv1[3].Split(cs) : Array.Empty<string>();

        var ev = new
        {
            eventCode,
            eventTime = FormatTs(msh[6]),
            mrn       = pid is not null ? pid[3].Split(cs)[0] : "",
            patient   = new
            {
                family = name.Length > 0 ? name[0] : "",
                given  = name.Length > 1 ? name[1] : "",
                dob    = pid is not null && pid.Length > 7 ? FormatDate(pid[7]) : "",
                sex    = pid is not null && pid.Length > 8 ? pid[8] : ""
            },
            location  = new
            {
                ward = loc.Length > 0 ? loc[0] : "",
                room = loc.Length > 1 ? loc[1] : "",
                bed  = loc.Length > 2 ? loc[2] : ""
            },
            messageId = msh.Length > 9 ? msh[9] : ""
        };

        return new PipelineMessage
        {
            Body        = JsonSerializer.Serialize(ev),
            MessageType = "PatientEvent"
        };
    }

    // Message id and error only, no patient data, safe for the alert email.
    private static PipelineMessage BuildParseFailure(List<string> segs, char fs, Exception ex)
    {
        var msh = segs[0].Split(fs);
        var failure = new
        {
            error     = $"Hl7AdtDisassembler: {ex.Message}",
            messageId = msh.Length > 9 ? msh[9] : ""
        };
        return new PipelineMessage
        {
            Body        = JsonSerializer.Serialize(failure),
            MessageType = "AdtPostFailed"
        };
    }

    private static string FormatTs(string ts) =>
        ts.Length >= 14
          ? $"{ts[..4]}-{ts.Substring(4,2)}-{ts.Substring(6,2)}T{ts.Substring(8,2)}:{ts.Substring(10,2)}:{ts.Substring(12,2)}"
          : ts;

    private static string FormatDate(string d) =>
        d.Length >= 8 ? $"{d[..4]}-{d.Substring(4,2)}-{d.Substring(6,2)}" : d;
}

5
Step Five
Create the Scheduler clock
General

Create the Scheduler receive port AdtClock, the five-minute tick that drives the fetch. Everything the ports from here on reference already exists, so each field is a pure selection. (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)

SettingValue
AdapterScheduler
Repeat Every5
Repeat UnitMinutes

6
Step Six
Create the fetch port
General

Create the two-way SFTP Caller send port AdtFetch against the HIS export directory, subscribing on {{Config.PortName}} == "AdtClock".

SettingValue
AdapterSFTP Caller
WayTwo
Hostsftp.his.example
AuthenticationHisSftp, from Step 2
CommandDownload
Remote Directory/adt/export
Filename*.hl7, every batch in the directory
Reference the disassembler

In the inbound pipeline, reference Hl7AdtDisassembler, from Step 4.

Agree the handover

Agree with the HIS team how fetched batches leave the directory, the recommendation is that their export job moves a collected file aside, or keeps a processed list, so the next tick is not offered the same batch again. A re-fetch that slips through is harmless to the index, the Step 3 upsert ignores stale events, it just costs processing.


7
Step Seven
Create the MPI upsert port
General

Create the SQL Caller send port MpiUpsert, subscribing on {{Message.MessageType}} == "PatientEvent".

SettingValue
AdapterSQL Caller
WayOne
Auth ConfigMpiDbSql, from Step 2
Command TypeStoredProcedure
Command Textdbo.UpsertPatient
Input Parameters

The procedure’s parameters go under Input Parameters, one key/value row each, binding the promotions defined on PatientEvent in Step 1:

ParameterValue
Mrn{{Promoted.PatientEvent.Mrn}}
Family{{Promoted.PatientEvent.Family}}
Given{{Promoted.PatientEvent.Given}}
Dob{{Promoted.PatientEvent.Dob}}
Sex{{Promoted.PatientEvent.Sex}}
Ward{{Promoted.PatientEvent.Ward}}
Room{{Promoted.PatientEvent.Room}}
Bed{{Promoted.PatientEvent.Bed}}
EventCode{{Promoted.PatientEvent.EventCode}}
EventTime{{Promoted.PatientEvent.EventTime}}

8
Step Eight
Wire the failure path
Set the Exception Message Type

On both send ports, AdtFetch and MpiUpsert, set Exception Message Type to AdtPostFailed, from Step 1; the disassembler’s per-message failures already publish under it, and an upsert the index rejects now lands there too. Turn on Activity Notifications (On Error Only) for unparseable batches.

Create the OpsAlert send port

Add an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "AdtPostFailed", MRN and event code in the body, no names:

SettingValue
AuthenticationO365OpsMail, from Step 2
Fromnoreply@acme.example
Toops@acme.example
SubjectADT post failed, MRN {{Promoted.AdtPostFailed.Mrn}}

9
Step Nine
Start the ports and test
Start in dependency order

There is nothing to deploy, everything you configured is already saved and live, because Art2link applies changes immediately. To bring the flow online you start the send ports first, then the receive port: start MpiUpsert, OpsAlert and AdtFetch, then start AdtClock. (To take the flow offline, stop the receive port first, for the same reason.)

Turn on tracking

Before you test, set Tracking to Enabled + Body on every port the flow touches, so you can walk each run step by step, with its message body, in tracking.

Trigger the tick

Drop a batch with an A01, A02 and A03 for the same MRN in the export directory, trigger the tick, and check the index lands on the final state.

Feed it out of order

Feed the same three events out of order and confirm last-write-wins by event time holds.

Break it on purpose

Corrupt one PID in a three-message batch and confirm the disassembler publishes that one message as AdtPostFailed, one alert, while the other two upsert cleanly.

Turn bodies off in production. Enabled + Body is the most expensive tracking level, extra processing and database space, and it records every payload including successful runs. Once the flow is proven, set the ports to Only on Error instead: failures still capture the full body for diagnosis, while healthy runs are not recorded.