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.
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:
{{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.
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.
{{Message.MessageType}} == "AdtPostFailed"
Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.
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:
| Name | Format | Purpose |
|---|---|---|
| PatientEvent | JSON | one canonical event per HL7 message, as the disassembler (Step 4) publishes it |
| AdtPostFailed | JSON | the 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.
| Promotion | Path |
|---|---|
| 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 |
A failed event re-publishes payload intact, but promotions are type-qualified, so the exception type needs its own:
| Promotion | Path |
|---|---|
| Mrn | $.mrn |
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.
| Setting | Value |
|---|---|
| Name | HisSftp |
| Adapter | SFTP Caller |
| Definition | SFTP Password Authentication |
| Username / Password | the export account credentials |
| Host Key Fingerprint | the HIS server’s pinned host key |
| Setting | Value |
|---|---|
| Name | MpiDbSql |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String (Database Config) | the master patient index database’s connection string, credentials included |
| Setting | Value |
|---|---|
| Name | O365OpsMail |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
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.
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;
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.
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
{
"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"
}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.
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; }
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.)
| Setting | Value |
|---|---|
| Adapter | Scheduler |
| Repeat Every | 5 |
| Repeat Unit | Minutes |
Create the two-way SFTP Caller send port AdtFetch against the HIS export directory, subscribing on {{Config.PortName}} == "AdtClock".
| Setting | Value |
|---|---|
| Adapter | SFTP Caller |
| Way | Two |
| Host | sftp.his.example |
| Authentication | HisSftp, from Step 2 |
| Command | Download |
| Remote Directory | /adt/export |
| Filename | *.hl7, every batch in the directory |
In the inbound pipeline, reference Hl7AdtDisassembler, from Step 4.
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.
Create the SQL Caller send port MpiUpsert, subscribing on {{Message.MessageType}} == "PatientEvent".
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | One |
| Auth Config | MpiDbSql, from Step 2 |
| Command Type | StoredProcedure |
| Command Text | dbo.UpsertPatient |
The procedure’s parameters go under Input Parameters, one key/value row each, binding the promotions defined on PatientEvent in Step 1:
| Parameter | Value |
|---|---|
| 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}} |
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.
Add an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "AdtPostFailed", MRN and event code in the body, no names:
| Setting | Value |
|---|---|
| Authentication | O365OpsMail, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | ADT post failed, MRN {{Promoted.AdtPostFailed.Mrn}} |
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.)
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.
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 the same three events out of order and confirm last-write-wins by event time holds.
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.