HL7 v2 ADT to FHIR in .NET 10: Synthetic Messages, Validated Output

Most of what happens to a patient in a hospital still leaves the patient administration system as an HL7 v2 ADT message: admitted, moved, updated, discharged, merged. Anything built on FHIR downstream of that system depends on a converter, and a converter is the kind of component that demonstrates well and runs badly. Turning a PID segment into a Patient takes an afternoon. Keeping a FHIR server correct while the same feed resends, arrives out of order, sends half a record and merges two patients into one takes considerably longer.

This article builds that second kind of converter in .NET 10 and proves what it produces. It handles six trigger events, runs twelve synthetic messages through them, applies the results to a FHIR R4 server, and puts every Bundle it sends through the HL7 validator. Where the server behaved in a way that shaped the design, the behaviour is shown. The strategy question of whether and when to move off v2 is covered in EMR Modernization: Migrating from Legacy HL7 v2 to FHIR; this one is about the mechanics.

ℹ️
SCOPE

Generic FHIR R4 with no national profile. Every patient, number, address and telephone number is invented, and identifier systems use the 2.999 example OID arc. The code is a teaching sample and is not clinically validated.

Requirements before any mapping

The mapping tables are the visible part of the work. The requirements that decide the design are about behaviour over time.

RequirementWhat it means here
Six trigger eventsA01 admit, A04 register, A08 update, A03 discharge, A11 cancel admit, A40 merge. Anything else is rejected, not half-converted.
IdempotentA message sent twice changes the server once. A feed that resends after a timeout must be harmless.
Ordered per patientAn older event that arrives late must not undo a newer one.
No silent data lossA field the message did not mention is left alone. Anything dropped or approximated in conversion is reported.
Valid outputEvery Bundle passes the HL7 validator for R4 with no errors before the build is green.
TraceableFor each change on the server, which message caused it can be answered from the server.
Honest acknowledgementsThe sender learns whether to resend, fix, or give up.

Architecture: a mapper that reads nothing, an applier that reads the server

The converter is split along one line: what can be decided from the message alone, and what needs the state of the server. The mapper takes a message and options and returns a transaction Bundle. It does no I/O and has no clock, so the same message always produces the same Bundle, byte for byte, and it can be tested without a server. The applier takes that Bundle, reads the server, and answers three questions the mapper cannot: has this message been applied before, has a later event already been applied, and has this patient record been merged into another.

Pipeline from an HL7 v2 ADT message through a reader, a mapper with no I/O and an applier to a FHIR R4 server, with the acknowledgement returned to the sender and the HL7 validator run in the build
The mapper depends only on the message. The applier is the only part that talks to the server.

There are no package references in the project. The v2 reader is about 150 lines, and FHIR resources are built as System.Text.Json nodes. That was a deliberate trade, and it is not the only reasonable one.

OptionAdvantagesDisadvantagesFits when
Own reader and JSON nodes (this article)Nothing to patch; reads any v2 version the same way; the output is exactly what you wroteNo compile-time FHIR types, so the validator in CI has to be the type checkA small, well-tested interface with a fixed set of events
NHapi for v2, Firely SDK for FHIRTyped v2 structures and typed FHIR resources; mistakes surface at compile timeVersion-specific v2 models meet senders that do not follow their declared version; two dependencies to keep currentA larger .NET integration estate that already uses both
Template conversion: an integration engine, or the Liquid templates behind $convert-dataMappings are configuration; analysts can change them; a managed option existsOrdering, duplicates and merges still need an owner; templates are harder to unit testMany message types and senders, with mapping changes more frequent than releases

Whichever option does the field mapping, the applier’s three questions remain. They are the part most often left out.

What the HL7 v2-to-FHIR guide gives you, and where it stops

HL7 published the first trial-use release of the Version 2 to FHIR implementation guide in October 2025. It is the right starting point: segment maps such as PID to Patient, PV1 to Encounter and AL1 to AllergyIntolerance, data type maps, and vocabulary maps such as patient class (table 0004) to the encounter class codes. This converter follows those where they apply. Encounter status, for example, is derived the way the guide specifies: finished when PV1-45 carries a discharge time, and from the patient class otherwise.

The guide’s message maps are a shorter list than most feeds. The ADT messages mapped in the trial-use release are A01, A02, A05, A06, A09, A11 and A17. A03, A04, A08 and A40 are not among them, and those four carry a large share of a typical feed. For them the segment maps still apply, and the decisions about the message as a whole are yours. These are the ones made here.

Table of the ADT events A01, A04, A08, A03, A11 and A40 against Patient, Encounter, AllergyIntolerance and Provenance, showing which resources each event writes
What each of the six events writes. Greyed cells are deliberate: the event has no business changing that resource.
  • A08 does not touch the Encounter. It carries a PV1, and applying it would let a demographic update reopen a finished stay. An encounter changes only through its own events.
  • NK1 becomes Patient.contact. The guide also maps it to RelatedPerson. NK1 has no identifier, so a RelatedPerson would be created again on every resend; a contact inside the Patient has no identity problem.
  • One Provenance per message, and no MessageHeader or Account. The guide’s A01 map produces a MessageHeader, up to three Provenance resources and an Account. One Provenance that names the patient, the encounter, the event time and the message control ID is enough to answer “which message did this”.
  • A40 links two Patient resources. The surviving record gets a replaces link and the retired one a replaced-by link and active: false.

Reading the message

This is the admission that the rest of the article follows.

MSH|^~\&|ADTSYS|GENHOSP|FHIRGW|REGION|20260310081500+0000||ADT^A01^ADT_A01|MSG00001|P|2.5.1
EVN|A01|20260310081500+0000
PID|1||700001^^^GENHOSP^MR~PT-55-0193^^^REGION^PI||O'Brien^Aoife^Mary^^Ms^^L~Byrne^Aoife^^^^^M||19850312|F|||Flat 2, Smith \T\ Sons Building^14 Example Street^Exampletown^^EX1 2MP^IE^H||^PRN^PH^^^^^^^^^+353 20 910 0101~^NET^Internet^aoife.obrien@example.org|^WPN^PH^^^^^^^^^+353 20 910 0102||M
NK1|1|O'Brien^Cian|SPO^Spouse^HL70063||^PRN^CP^^^^^^^^^+353 20 910 0103||C^Emergency Contact^HL70131
PV1|1|I|W4^R12^B^GENHOSP||||D1001^Murphy^Niamh^^^Dr^^^GENHOSP||||||||||||V9001^^^GENHOSP^VN|||||||||||||||||||||||||20260310080000+0000|
AL1|1|DA|91936005^Allergy to penicillin^SCT|SV|Rash|20190601
AL1|2|FA|91935009^Allergy to peanut^SCT|MO

Four things in a v2 reader decide whether it survives real senders. The delimiters are declared in MSH-1 and MSH-2 and must be read from there. Free text can contain those delimiters as escape sequences, such as \T\ for the ampersand in “Smith & Sons”. Two double quotes are not an empty string; they are an instruction to delete. And a segment nobody maps, such as a local Z-segment, must be kept and reported, not skipped silently.

    private string Unescape(string text)
    {
        if (!text.Contains(_d.Escape)) { return text; }
        StringBuilder result = new(text.Length);
        for (int i = 0; i < text.Length; i++)
        {
            if (text[i] != _d.Escape) { result.Append(text[i]); continue; }
            int end = text.IndexOf(_d.Escape, i + 1);
            if (end < 0) { result.Append(text, i, text.Length - i); break; }   // a lone escape character: keep what was sent
            string code = text[(i + 1)..end];
            switch (code)
            {
                case "F": result.Append(_d.Field); break;
                case "S": result.Append(_d.Component); break;
                case "T": result.Append(_d.SubComponent); break;
                case "R": result.Append(_d.Repetition); break;
                case "E": result.Append(_d.Escape); break;
                case ".br": result.Append('\n'); break;
                default:
                    if (code.Length > 1 && code[0] == 'X' && code.Length % 2 == 1 && code.Skip(1).All(Uri.IsHexDigit))
                    {
                        byte[] bytes = Convert.FromHexString(code.AsSpan(1));
                        result.Append(Encoding.UTF8.GetString(bytes));
                    }
                    // Anything else is formatting (\H\, \N\, \.sp\ and so on) with no place in a FHIR string: dropped.
                    break;
            }
            i = end;
        }
        return result.ToString();
    }

The self-test covers each escape, a message with five non-standard delimiters, an MLLP-framed message, the explicit null, and four inputs that must be refused: no MSH, a repeated delimiter, an empty control ID, and a line that is not a segment.

Identity: authority and type together

Everything the converter writes is matched on a business identifier, never on a server id it would have to remember. For that, each v2 identifier needs a FHIR system, and the assigning authority in CX-4 is not enough to pick one. The same hospital issues record numbers, visit numbers and staff numbers from different sequences, and 700001 can exist in all three. The lookup key is therefore the authority and the identifier type together.

    /// <summary>Reads a CX or XCN and resolves its system from the assigning authority and identifier type.</summary>
    private ResourceKey? Key(Hl7Segment segment, int field, int repetition, string type, int authorityComponent = 4)
    {
        string value = segment.Value(field, 1, 1, repetition);
        if (value.Length == 0) { return null; }
        string authority = segment.Value(field, authorityComponent, 1, repetition);
        if (options.IdentifierSystems.TryGetValue($"{authority}|{type}", out string? system)) { return new ResourceKey(system, value); }
        // HD.2 and HD.3: a universal ID that says it is an ISO object identifier needs no configuration.
        string universal = segment.Value(field, authorityComponent, 2, repetition);
        return universal.Length > 0 && segment.Value(field, authorityComponent, 3, repetition) == "ISO" ? new ResourceKey($"urn:oid:{universal}", value) : null;
    }

When the patient’s record number comes from an authority that is not configured, the message is answered with an error. It is not converted with a guessed or empty system. An identifier without a system matches nothing reliably, and the first time two hospitals share a number range it will match the wrong patient.

Two things in an ADT message have no identifier at all and still must not be duplicated on a resend. An AL1 allergy row gets an identifier derived from what makes it the same row: the patient and the allergen. The Provenance for a message gets a resource id derived from the sender and the control ID in MSH-10. Both use a name-based UUID, so the same input always gives the same value.

        // AL1 has no identifier of its own. Without one, every resend would create the allergy again, so the
        // identity is derived from what makes the row the same row: the patient and the allergen.
        string identifier = Urn($"allergy|{patientKey}|{system}|{(code.Length > 0 ? code : text.ToUpperInvariant())}");

Dates and times at the precision received

A v2 timestamp can be a year, a month, a day, or a time to the minute or second, with or without an offset. FHIR accepts partial dates and refuses a time of day that has no offset. The rule here is to keep exactly the precision that was sent, use the offset from MSH-7 or a configured default when a time has none, record that it was assumed, and refuse anything that is not a date.

using System.Globalization;

namespace AdtToFhir.Core;

/// <summary>An HL7 v2 date or date/time, converted to FHIR at the precision the sender gave and no finer.</summary>
public readonly record struct V2Time(string Fhir, DateTimeOffset? Instant, bool OffsetAssumed)
{
    private static readonly string[] DateFormats = ["yyyy", "yyyyMM", "yyyyMMdd"];
    private static readonly string[] TimeFormats = ["yyyyMMddHH", "yyyyMMddHHmm", "yyyyMMddHHmmss"];

    /// <summary>
    /// Returns null when the value is empty or is not a v2 time. A value with a time of day and no offset gets
    /// <paramref name="defaultOffset"/>, because FHIR does not allow a time without one; with no default it is
    /// cut back to the date.
    /// </summary>
    public static V2Time? Parse(string value, TimeSpan? defaultOffset)
    {
        value = value.Trim();
        if (value.Length == 0) { return null; }

        TimeSpan? offset = null;
        int sign = value.LastIndexOfAny(['+', '-']);
        if (sign >= 4)
        {
            string zone = value[(sign + 1)..];
            if (zone.Length != 4 || !zone.All(char.IsAsciiDigit)) { return null; }
            TimeSpan size = new(int.Parse(zone[..2], CultureInfo.InvariantCulture), int.Parse(zone[2..], CultureInfo.InvariantCulture), 0);
            if (size > TimeSpan.FromHours(14)) { return null; }
            offset = value[sign] == '-' ? -size : size;
            value = value[..sign];
        }
        int dot = value.IndexOf('.');
        if (dot >= 0) { value = value[..dot]; }          // fractions of a second are dropped, not rounded
        if (!value.All(char.IsAsciiDigit)) { return null; }

        if (value.Length <= 8)
        {
            if (!DateTime.TryParseExact(value, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime date)) { return null; }
            string text = value.Length switch { 4 => $"{date:yyyy}", 6 => $"{date:yyyy-MM}", _ => $"{date:yyyy-MM-dd}" };
            return new V2Time(text, null, false);
        }
        if (!DateTime.TryParseExact(value, TimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime local)) { return null; }
        if ((offset ?? defaultOffset) is not TimeSpan known)
        {
            return new V2Time($"{local:yyyy-MM-dd}", null, false);
        }
        DateTimeOffset instant = new(local, known);
        return new V2Time(instant.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture), instant, offset is null);
    }

    /// <summary>The date part only, for elements such as Patient.birthDate that cannot carry a time.</summary>
    public string DateOnly => Fhir.Length > 10 ? Fhir[..10] : Fhir;
}

From the run:

  1985                       no default offset  -> 1985
  198503                     no default offset  -> 1985-03
  19850312                   no default offset  -> 1985-03-12
  20260310081500+0000        no default offset  -> 2026-03-10T08:15:00+00:00
  202603100815+0100          no default offset  -> 2026-03-10T08:15:00+01:00
  20260310081500.1234-0500   no default offset  -> 2026-03-10T08:15:00-05:00
  20260310081500             default +00:00     -> 2026-03-10T08:15:00+00:00
  20260310081500             no default offset  -> 2026-03-10
  20260230                   no default offset  -> (not a time)
  2026031025                 default +00:00     -> (not a time)
  10/03/2026                 no default offset  -> (not a time)
  (empty)                    no default offset  -> (not a time)

The seventh and eighth rows are the same input with and without a default offset. With no offset available the time is cut back to a date. A converter that invents Z for a local time has moved an admission by an hour for half the year in any country with summer time.

Codes that look alike and are not

Copying a v2 code into a FHIR element is the most common way to produce a resource that parses and is wrong. Three cases from the tables used here:

Fieldv2 valueWhat it becomesWhy
PID-8 sex (table 0001)Fgender: femaleFHIR uses words, and the binding is required
PID-8 sexX (not in the table)gender: unknown, with a noteAn unrecognised code is reported, not passed through
PID-16 marital status (table 0002)AText “Separated”, no codev2 “A” is separated; the FHIR marital status code “A” is annulled
PV1-2 patient class (table 0004)I, O, EIMP, AMB, EMERFrom the guide’s vocabulary map
AL1-4 severity (table 0128)SV with a reaction in AL1-5reaction.severity: severeSeverity describes the reaction when there is one
AL1-4 severityMO with no reactioncriticality: lowThe guide offers both targets; with no reaction to attach it to, criticality is used
    // Table 0002 and v3 MaritalStatus share letters that mean different things: v2 "A" is separated, v3 "A" is annulled.
    private static readonly Dictionary<string, (string Code, string? Display)> Marital = new()
    {
        ["M"] = ("M", "Married"), ["S"] = ("S", "Never Married"), ["D"] = ("D", "Divorced"), ["W"] = ("W", "Widowed"),
        ["N"] = ("A", null), ["E"] = ("L", null), ["P"] = ("T", null), ["I"] = ("I", null), ["B"] = ("U", null),
    };

Every approximation produces a note that travels with the result. This is the older v2.3.1 message in the test set, and what the mapper said about it:

MSH|^~\&|ADTSYS|GENHOSP|FHIRGW|REGION|202603131015||ADT^A04|MSG00008|P|2.3.1
EVN|A04|202603131015
PID|1||700003^^^GENHOSP^MR||Walsh^Tom^^^^^L||198011|X|||3 Test Lane \F\ Rear Annex^^Exampletown^^EX2 9ZZ^IE^H||""|||A^Separated
NK1|1||MTH^Mother
PV1|1|O|OPD^^^GENHOSP||||D1001^Murphy^Niamh^^^Dr^^^GENHOSP||||||||||||V9003^^^GENHOSP^VN|||||||||||||||||||||||||202603131000|
AL1|1|MA|^House dust
ZPD|1|LOCALFLAG
  08-a04-older-sender.hl7            Patient, Encounter, AllergyIntolerance, Provenance
      note: PID-8 'X' is not in table 0001; gender is recorded as unknown.
      note: PID-16 'A' has no equivalent in the FHIR marital status codes; it is carried as text.
      note: An NK1 with no name, telephone or address was left out: FHIR requires at least one of them.
      note: AL1-2 'MA' has no FHIR allergy category; none is recorded.
      note: Segment ZPD is not mapped (1 in this message).
      note: At least one time had no offset; +00:00 was assumed.

A v2 message is not a snapshot

This is the design point with the most patient impact, and a converter can pass every mapping test and still get it wrong. The obvious implementation maps the message to a Patient and writes it with PUT. A PUT replaces the resource. A discharge message that carries no NK1 segment then deletes the patient’s emergency contact, and an update that fills three PID fields deletes everything else.

HL7 v2 says something different. An empty field means the sender has nothing to say about it. A value means “this is the state now”. Two double quotes mean “delete what you hold”. The mapper therefore reports which parts of the Patient the message speaks for:

        // An empty field says nothing; a value or two double quotes says "this is the state now".
        (string Slot, int Field)[] slots = [("name", 5), ("birthDate", 7), ("gender", 8), ("address", 11), ("telecom:home", 13), ("telecom:work", 14), ("maritalStatus", 16), ("deceased", 30)];
        List<string> replaces = [.. slots.Where(slot => pid.Raw(slot.Field).Length > 0).Select(slot => slot.Slot)];
        if (message.First("NK1") is not null) { replaces.Add("contact"); }

When the patient already exists, the applier starts from the resource on the server, clears only those parts, and lays the mapped values over it. Identifiers accumulate. Links written by a merge are never touched, because no PID field owns them.

    private static JsonObject Overlay(JsonObject current, JsonObject mapped, IReadOnlyList<string> replaces)
    {
        JsonObject merged = (JsonObject)current.DeepClone();
        merged.Remove("meta");
        merged.Remove("text");
        foreach (string slot in replaces)
        {
            switch (slot)
            {
                case "telecom:home" or "telecom:work":
                    JsonArray kept = [.. (merged["telecom"]?.AsArray() ?? []).Where(t => (t!["use"]?.GetValue<string>() == "work") != (slot == "telecom:work")).Select(t => t!.DeepClone())];
                    merged.Remove("telecom");
                    if (kept.Count > 0) { merged["telecom"] = kept; }
                    break;
                case "deceased": merged.Remove("deceasedBoolean"); merged.Remove("deceasedDateTime"); break;
                default: merged.Remove(slot); break;
            }
        }
        foreach ((string name, JsonNode? value) in mapped)
        {
            switch (name)
            {
                case "telecom":
                    merged["telecom"] = new JsonArray([.. (merged["telecom"]?.AsArray() ?? []).Select(t => t!.DeepClone()), .. value!.AsArray().Select(t => t!.DeepClone())]);
                    break;
                case "identifier":
                    // Identifiers accumulate: no ADT message here can say that a number no longer belongs to a patient.
                    JsonArray known = [.. (merged["identifier"]?.AsArray() ?? []).Select(i => i!.DeepClone())];
                    foreach (JsonNode? sent in value!.AsArray())
                    {
                        if (!known.Any(i => i!["system"]?.GetValue<string>() == sent!["system"]?.GetValue<string>() && i["value"]?.GetValue<string>() == sent["value"]?.GetValue<string>())) { known.Add(sent!.DeepClone()); }
                    }
                    merged["identifier"] = known;
                    break;
                case "period" when merged["period"] is JsonObject period:
                    foreach ((string edge, JsonNode? instant) in value!.AsObject()) { period[edge] = instant!.DeepClone(); }
                    break;
                default: merged[name] = value!.DeepClone(); break;
            }
        }
        return merged;
    }

A sparse update shows it. The patient was registered earlier with a birth date, a gender, a marital status and an address. This message sends a name, a telephone number and an explicit null for the address:

MSH|^~\&|ADTSYS|GENHOSP|FHIRGW|REGION|20260314130000+0000||ADT^A08^ADT_A01|MSG00016|P|2.5.1
EVN|A08|20260314130000+0000
PID|1||700003^^^GENHOSP^MR||Walsh^Tom^^^^^L||||||""||^PRN^PH^^^^^^^^^+353 20 910 0188

The Patient entry of the transaction that was sent keeps what the message did not mention, drops the address, and names the version it was built from:

    {
      "fullUrl": "http://127.0.0.1:8082/fhir/Patient/16",
      "resource": {
        "resourceType": "Patient",
        "id": "16",
        "identifier": [
          {
            "type": {
              "coding": [
                {
                  "system": "http://terminology.hl7.org/CodeSystem/v2-0203",
                  "code": "MR",
                  "display": "Medical record number"
                }
              ]
            },
            "system": "urn:oid:2.999.1.1",
            "value": "700003"
          }
        ],
        "active": true,
        "gender": "unknown",
        "birthDate": "1980-11",
        "maritalStatus": {
          "text": "Separated"
        },
        "name": [
          {
            "use": "official",
            "family": "Walsh",
            "given": [
              "Tom"
            ]
          }
        ],
        "telecom": [
          {
            "system": "phone",
            "value": "+353 20 910 0188",
            "use": "home"
          }
        ]
      },
      "request": {
        "method": "PUT",
        "url": "Patient/16",
        "ifMatch": "W/\"1\""
      }
    }
⚠️
PATIENT SAFETY

A converter that replaces instead of overlaying loses data without an error anywhere: the message is valid, the Bundle is valid, the server answers 200, and a next-of-kin contact or an address is gone. Test for it explicitly, with a message that omits a segment the previous message carried. The same reasoning has a limit in the other direction: this sample does not remove an allergy that a later message stops listing, because an AL1 list in an ADT message is not a reliable statement that an allergy has ended.

Seen before, out of order, merged away

Before anything is written, the applier asks the server three questions. Each has one cheap read behind it.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    M["Mapped message"] --> Q1{"Provenance with this
message id exists?"} Q1 -- "yes" --> D["Duplicate:
nothing sent, ACK AA"] Q1 -- "no" --> Q2{"Patient retired
by a merge?"} Q2 -- "yes" --> R["Refused:
nothing sent, ACK AE"] Q2 -- "no" --> Q3{"Later event already
applied to this patient?"} Q3 -- "yes" --> S["Stale:
nothing sent, ACK AA"] Q3 -- "no" --> T["Overlay, then one transaction
with ifMatch"] T -- "200" --> A["Applied: ACK AA"] T -- "409" --> M style D fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74 style S fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74 style R fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B style A fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202 style T fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
    public async Task<Prepared> PrepareAsync(MapResult mapped, CancellationToken cancel = default)
    {
        // 1. Seen before? The Provenance id is derived from the sender and MSH-10, so a resend finds its own record.
        if (await GetAsync($"Provenance/{mapped.ProvenanceId}", cancel) is not null)
        {
            return new Prepared(ApplyOutcome.Duplicate, $"message {mapped.ControlId} was applied before", null);
        }

        JsonObject bundle = (JsonObject)mapped.Bundle.DeepClone();
        JsonArray entries = bundle["entry"]!.AsArray();
        JsonObject patientEntry = entries[0]!.AsObject();
        JsonObject patient = patientEntry["resource"]!.AsObject();
        string placeholder = patientEntry["fullUrl"]!.GetValue<string>();
        string survivor = placeholder;

        JsonObject? current;
        try { current = await FindOneAsync("Patient", mapped.Patient, cancel); }
        catch (InvalidOperationException error) { return new Prepared(ApplyOutcome.Failed, error.Message, null); }

        if (current is not null)
        {
            string id = current["id"]!.GetValue<string>();

            // 2. Merged away? A retired record must not come back to life because an old feed still sends to it.
            if (current["active"]?.GetValue<bool>() == false && LinkOfType(current, "replaced-by") is string replacement)
            {
                return new Prepared(ApplyOutcome.PatientMerged, $"{mapped.Patient.Value} is retired and replaced by {replacement}", null);
            }

            // 3. Out of order? Compare the event time with the latest event already applied to this patient.
            if (await LatestEventAsync(id, cancel) is DateTimeOffset latest && latest > mapped.EventTime)
            {
                return new Prepared(ApplyOutcome.Stale, $"event of {mapped.EventTime:u} is older than {latest:u}, already applied", null);
            }

            // The update names the version that was just read. If another writer got in between, the server refuses.
            survivor = $"Patient/{id}";
            patient = Overlay(current, patient, mapped.Replaces);
            patientEntry["resource"] = patient;
            Pin(patientEntry, current, survivor);
            Repoint(entries, placeholder, survivor);
        }

The searches carry Cache-Control: no-cache. A FHIR server may answer a repeated search from a short-lived cache, and a duplicate check that reads a stale answer is not a check.

These are the nineteen steps of the run against HAPI FHIR, in order. The indented lines under a step are what the converter reported or what was checked straight after it.

== against http://127.0.0.1:8082/fhir/
ok     1. a01-admit: AA, Applied
ok     2. a01-admit-sent-again: AA, Duplicate
        message MSG00001 was applied before
ok     3. a08-update: AA, Applied
ok     4. a03-discharge: AA, Applied
ok        the discharge carried no NK1, and the emergency contact is still on the patient
ok        the discharge added an end to the stay and kept its start, location and doctor
ok     5. a08-update-arriving-late: AA, Stale
        event of 2026-03-10 11:30:00Z is older than 2026-03-12 13:30:00Z, already applied
ok     6. a01-second-registration: AA, Applied
ok     7. a11-cancel-admit: AA, Applied
ok     8. a40-merge: AA, Applied
ok     9. a08-to-retired-record: AE, PatientMerged
        700002 is retired and replaced by Patient/2
ok    10. a08-after-merge: AA, Applied
ok    11. a04-older-sender: AA, Applied
ok    12. a02-transfer: AR
        ADT^A02 is not a message this interface converts.
ok    13. a01-no-record-number: AE
        PID-3 has no medical record number (type MR) from a known assigning authority.
ok    14. a08-second-writer: AA, Applied
ok    15. a write prepared before another writer's update is refused by the server
        HTTP 409: HAPI-0550: HAPI-0989: Trying to update Patient/2/_history/4 but this is not the current version
ok    16. a08-first-writer-retried: AA, Stale
        event of 2026-03-14 10:00:00Z is older than 2026-03-14 10:05:00Z, already applied
ok    17. a40-merge-of-a-record-never-seen: AA, Applied
ok    18. a08-with-nothing-new: AA, Applied
        Patient version before 6, after 6
ok        an update that changes nothing writes no new Patient version
ok    19. a08-sparse-update: AA, Applied
ok        a sparse update kept the birth date, gender and marital status it did not mention
ok        it removed the address sent as an explicit null and added the telephone

Four of these deserve a comment.

Steps 2 and 5 are acknowledged with AA although nothing was applied. That is deliberate. AE tells a sender to try again, and a sender that receives AE for a duplicate or for a superseded update will resend it for ever. The message arrived and was dealt with; what was not applied goes to the operator’s log, not back down the wire.

        // Duplicate and Stale are answered AA on purpose: the message arrived and was dealt with, and a sender
        // that receives AE will send it again for ever. What was not applied is in Detail for the operator.
        (string code, string text) reply = result.Outcome switch
        {
            ApplyOutcome.Applied => ("AA", "applied"),
            ApplyOutcome.Duplicate => ("AA", $"not applied: {result.Detail}"),
            ApplyOutcome.Stale => ("AA", $"not applied: {result.Detail}"),
            _ => ("AE", result.Detail),
        };

Step 15 is the server enforcing ifMatch inside a transaction. One writer read the patient at version 4, a second writer updated it, and the first writer’s transaction was refused with HTTP 409 and nothing in it was applied. On the retry in step 16 the converter read again and found its event was now the older one.

Step 18 shows the limit of that protection. An update that changes nothing in the Patient writes no new version on this server: the version was 6 before and 6 after. So ifMatch detects a concurrent change to the Patient resource, and it does not detect a concurrent message that left the Patient as it was. The primary control for ordering is therefore upstream of the converter: one consumer per patient, with the record number as the partition key if the messages pass through a queue. The version check is the backstop.

Step 9 refuses a write to a retired record. That is the next section.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','signalColor':'#0073BB','signalTextColor':'#0F172A','actorBkg':'#FFF3E0','actorBorder':'#FF9900','actorTextColor':'#232F3E','noteBkgColor':'#FFF8E7','noteBorderColor':'#FF9900','noteTextColor':'#232F3E'}}}%%
sequenceDiagram
    participant S as Sending system
    participant C as Converter
    participant F as FHIR server
    S->>C: ADT message
    C->>C: Read and map, no I/O
    C->>F: GET Provenance by derived id
    F-->>C: 404, not seen before
    C->>F: Search Patient by record number
    F-->>C: Patient at version 4
    C->>F: Search latest Provenance for the patient
    F-->>C: Time of the last event applied
    C->>F: POST transaction with ifMatch version 4
    F-->>C: 200, or 409 if the version moved
    C-->>S: ACK with AA, AE or AR

Merges: link, retire, and what stays behind

An A40 says that two record numbers belong to one person and names the survivor. In the test set the same patient is registered a second time in the emergency department under a new number, that visit is cancelled, and the two records are merged.

The converter updates the surviving Patient and adds a replaces link. It reads the retired Patient, keeps its details, sets active to false and adds a replaced-by link, in the same transaction. If the record to retire was never received, it writes an inactive stub with the old number and the link, so the old number still resolves and still says where the patient went. From then on, any message addressed to the retired number is refused with AE and the reason: in step 9, “700002 is retired and replaced by Patient/2”.

The run also confirms what this does not do, and it matters. After the merge, the cancelled emergency visit still refers to the retired Patient. The merge links two records; it does not move the retired record’s encounters and allergies to the survivor.

⚠️
PATIENT SAFETY

After a merge, clinical data can sit on a record marked inactive. A consumer that searches by the surviving patient id alone will not see it. Either every consumer follows Patient.link, or the merge re-points the dependent resources, and that decision belongs in the design, with a test. A converter must also never let an inactive, replaced record be updated back into use by a system that has not heard about the merge.

Validating what was sent

“Validated output” has to mean the Bundles that were sent, not examples written for the purpose. The self-test saves every transaction it posts, and the build runs the HL7 validator, version 6.8.2 with a pinned checksum, over all of them against FHIR 4.0.1. The decision is read from the OperationOutcome the validator writes, not from its exit code.

To show that the validator is looking, the build also validates two resources made the way a first attempt is made, by copying v2 values across unchanged. Those must fail, and the build breaks if they pass.

ok    sent/01-a01-admit.json                         0 error(s), 9 warning(s)
ok    sent/03-a08-update.json                        0 error(s), 12 warning(s)
ok    sent/04-a03-discharge.json                     0 error(s), 9 warning(s)
ok    sent/06-a01-second-registration.json           0 error(s), 5 warning(s)
ok    sent/07-a11-cancel-admit.json                  0 error(s), 7 warning(s)
ok    sent/08-a40-merge.json                         0 error(s), 7 warning(s)
ok    sent/10-a08-after-merge.json                   0 error(s), 12 warning(s)
ok    sent/11-a04-older-sender.json                  0 error(s), 7 warning(s)
ok    sent/14-a08-second-writer.json                 0 error(s), 12 warning(s)
ok    sent/17-a40-merge-of-a-record-never-seen.json  0 error(s), 8 warning(s)
ok    sent/18-a08-with-nothing-new.json              0 error(s), 12 warning(s)
ok    sent/19-a08-sparse-update.json                 0 error(s), 5 warning(s)
ok    naive/encounter.json                           1 error(s), 2 warning(s)  (errors expected)
        error at Encounter.period.start: Not a valid date/time format: '20260310080000+0000'
ok    naive/patient.json                             4 error(s), 1 warning(s)  (errors expected)
        error at Patient.identifier[0]: Identifier.system must be an absolute reference, not a local reference
        error at Patient.gender: The System URI could not be determined for the code 'F' in the ValueSet 'http://hl7.org/fhir/ValueSet/administrative-gender|4.0.1'
        error at Patient.gender: The value provided ('F') was not found in the value set 'AdministrativeGender' (http://hl7.org/fhir/ValueSet/administrative-gender|4.0.1), and a code is required from this value set  (error message = 
        error at Patient.birthDate: Not a valid date format: '19850312'

Twelve Bundles, no errors. The naive Patient fails four times over: an identifier system that is not a URI, a gender of “F” reported twice, and a birth date still in v2 form. The naive Encounter fails on its timestamp.

The 105 warnings on the real Bundles are worth reading once and then leaving alone, with reasons.

WarningCountWhy it stays
No narrative (a best-practice recommendation)46A converter has no business composing human-readable clinical text
A relative reference from a urn:uuid entry to a resource that is in the Bundle under its server URL24See below
A code from an HL7 v2 table where the binding is extensible: identifier types VN and PI, the NK1 relationship, the trigger event as Provenance activity33The v2 code is the accurate one; the bound value set has no equivalent
Marital status carried as text with no code2Intended: the alternative was a wrong code

The second row has a cause worth knowing. When the patient already exists, the Bundle updates it by id, and the new resources in the same Bundle refer to it as Patient/2. The validator would prefer an absolute URL there. The server would not: an absolute URL to one of its own resources was refused as an external reference, with error HAPI-0507. The relative reference is what the server resolves, so the warning stays.

The validator runs without a terminology server, so the build does not depend on a public service. That leaves one gap, which is that it cannot say whether a SNOMED CT code exists or whether the text beside it is one of that concept’s terms. A separate step asks a terminology server about each external coding in the sent Bundles with $validate-code:

ok    http://snomed.info/sct 91935009  sent 'Allergy to peanut'  server 'Allergy to peanut'
ok    http://snomed.info/sct 91936005  sent 'Allergy to penicillin'  server 'Allergy to penicillin'

2 distinct external coding(s) checked against https://tx.fhir.org/r4: all valid

Running it

The sample stops at the pipeline class: text in, acknowledgement out. What surrounds it in production is ordinary, and each piece has a reason.

  • Transport. An MLLP listener, or a queue fed by the integration engine, hands each message to the pipeline and returns its acknowledgement. Process one patient’s messages in order; different patients can run in parallel.
  • Logging. One structured record per message: control ID, trigger event, record number, outcome, acknowledgement code and the mapper’s notes. The notes are the early warning for a sender that has started using a new code or a new segment. Keep names, addresses and message bodies out of general application logs.
  • Monitoring. Count outcomes by type. A rise in Stale means the feed is being reordered upstream. Any PatientMerged means a system is still writing to a retired number and somebody needs to be told. Repeated conflicts on one patient mean two consumers are processing the same record.
  • Error handling. AE and AR messages go to a holding queue with the reason, where a person can fix the configuration and replay them. Replays are safe because every write is idempotent.
  • Security. The converter’s credential needs to read and write four resource types and nothing else. On the v2 side, MLLP has no security of its own and belongs inside a private network or a TLS tunnel.
  • Testing and CI. The mapper’s tests need no server. The server scenarios need a disposable one, which in the sample is a container started by the build. Pin the validator version and its checksum, because an unpinned validator changes what “valid” means between two builds.

The same read-then-write-with-ifMatch transaction is the basis of the prescription state changes in ePrescribing on FHIR R4; the pattern is general.

⚖️
AUDIT AND DATA PROTECTION

The Provenance per message is what lets you answer, from the server alone, which message set a value and when the event happened. It records the sending application and facility, not a person. If your audit requirements need the user who made the change, EVN-5 carries the operator in many feeds and is the field to map. A holding queue of failed messages contains full patient demographics and needs the same access control and retention rules as the server.

Cost

There is nothing to license in this design: the converter is your own code, and the validator and the test server are open source. The costs are operational, and they come in this order.

CostDriverHow to keep it down
Per-sender configurationEvery sending system has its own assigning authorities, local tables and habitsRefuse unknown authorities loudly, so configuration is found in testing and not in production
FHIR server requestsFour or five per message: three reads, one more for an encounter event, and the transactionAcceptable for ADT volumes; if it is not, cache the duplicate check, never the patient read
OperationsHeld messages need a personGood reasons in the acknowledgement and the log shorten every investigation
Build timeA container start and a validator run per buildRun the server scenarios on merge; run the mapper tests on every push

An integration engine moves the first row into its own mapping tools and adds a licence. It does not remove the row.

Decision framework

Your situationChooseReason
A handful of ADT events from one or two senders into your own FHIR serverA converter like this oneSmall, fully tested, and the behaviour under resend and reorder is yours to define
Many message types and many senders, with an integration teamAn integration engine for mapping, and keep an applier in front of the serverMapping is configuration there; idempotency, ordering and merges still need code
A managed FHIR service and a wish to own as little as possibleThe platform’s template conversion, plus an applierTemplates produce a Bundle; they do not decide whether to send it
A national or regional profile to conform toAny of the above, with the profile loaded into the validatorBase R4 validity is necessary and nowhere near sufficient for a profile
Results, orders or documents (ORU, ORM, MDM)A separate designIdentity, ordering and correction rules differ from ADT; do not stretch this one

Key takeaways

  • Split the converter into a mapper that reads only the message and an applier that reads the server. The first is testable without infrastructure; the second is where correctness over time lives.
  • A v2 message is not a snapshot. Overlay what the message sent on what the server holds, honour explicit nulls, and test with a message that omits a segment.
  • Resolve identifier systems from assigning authority and identifier type together, and refuse what is not configured. Derive identifiers for things v2 does not identify, so a resend cannot duplicate them.
  • Use the HL7 v2-to-FHIR guide for segments and vocabularies, and expect to make your own decisions for A03, A04, A08 and A40, which the trial-use release does not map at message level.
  • Validate the Bundles you send, in the build, with a pinned validator, and prove the gate works by feeding it something wrong.

If you run an ADT feed into FHIR, I would like to hear how you handle merges, and whether you re-point the retired record’s data. Leave a comment.

📝
HOW THIS WAS CHECKED

Everything shown ran in GitHub Actions on a hosted Linux runner with .NET SDK 10.0.201 (runtime 10.0.5) installed into a private directory, HAPI FHIR 8.0.0 in a container, and HL7 validator 6.8.2. The reader, date, mapping and acknowledgement checks passed 49 of 49 without a server. The nineteen steps against the server and the checks of the state they left passed 35 of 35. A Python script that shares no code with the converter read the server afterwards and passed 13 of 13. The validator reported no errors on the 12 Bundles that were sent and five errors on the two deliberately naive resources. Both SNOMED CT codings were confirmed with $validate-code against tx.fhir.org. The server behaviours described, the 409 on a stale ifMatch, no new version for an unchanged update, and the refusal of an absolute reference, are observations of HAPI FHIR 8.0.0 and may differ on another server. Not run: an MLLP listener, concurrent load, any server other than HAPI, any national profile, and message types other than the twelve in the sample. The run is here.

Glossary

  • ADT— Admit, discharge, transfer: the HL7 v2 message family for patient administration events.
  • Trigger event— The code in MSH-9 that says what happened, such as A01 for an admission or A40 for a merge.
  • PID, PV1, NK1, AL1, MRG— Segments for patient identification, the visit, next of kin, an allergy, and the record being merged away.
  • Assigning authority— The organisation or system that issued an identifier, carried in the fourth component of a CX value.
  • Explicit null— Two double quotes in a v2 field, meaning “delete the value you hold”. An empty field means “nothing to say”.
  • Transaction Bundle— A set of FHIR writes that the server applies together or not at all.
  • Conditional update— A PUT addressed by a search, such as an identifier, so the client need not know the server id.
  • ifMatch— The version a write expects to replace. If the resource has moved on, the server refuses the write.
  • Provenance— A FHIR resource that records what caused a change. Here, one per message applied.
  • MLLP— Minimal Lower Layer Protocol, the framing used to carry HL7 v2 messages over TCP.
  • Extensible binding— A value set that should be used when it has a suitable code, and may be left when it does not.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.