Skip to main content

HL7 v2 messaging — How it works

Overview

The hl7 module speaks HL7 v2 to the systems a hospital already runs: a radiology information system that owns its own worklist, a ward's bed-management board, a laboratory analyser feed. It is a domain module, so it imports only common and exchange and reaches the platform over Feign — the same call whether it is bundled in the single jar or split out later.

Two directions, both recorded in one place:

DirectionMessagesTicket
OutboundORM^O01 imaging orders (new / modified / cancelled)M12-007A
OutboundADT^A01/A02/A03 patient movementsM12-006
InboundORU^R01 results, filed as concept-coded observationsM12-007A

Data model & ownership

TablePurpose
integration_messageEvery message in or out: direction, type, control id, status, attempts, and a PHI-free summary. Outbound ADT rows also carry source_event_id (the movement's encounter) and emit_source (which path sent it).

The ledger the ADT reconciliation sweep reads — patient_movement_outbox — is owned by clinical, not by this module. hl7 reaches it over Feign like everything else in the platform.

No raw message body is ever stored, from the first row written. A stored HL7 message is a patient record in a table nobody treats as one — it would sit outside every access control the platform applies to the same facts held relationally.

Key rules & invariants

  • Recorded before it is attempted. The message row is committed first, so a send that fails — or a process that dies mid-send — leaves the message still owed and still visible. Recording after sending loses exactly the messages the log exists for.
  • A receipt is not a delivery. The 202 from an outbound endpoint means we hold this message and will keep trying, never the other system has it. A caller that treats it as confirmation shows a clinician a delivery that never happened.
  • Nowhere to send it stays PENDING. An unconfigured channel is an unfinished deployment, not a failure; marking those FAILED buries the real ones.
  • A message that cannot be built is REJECTED. Retrying it unchanged fails identically — it needs a person, not a queue.
  • Unconfigured MLLP binds nothing. The listener and sender are created only when configured, so a deployment that does not speak HL7 starts normally.

ADT feeds (M12-006)

Three triggers, and they are not interchangeable — the receiving system keys its bed state on which one arrives. An A01 for a transfer double-admits; an A03 for a transfer empties a bed that is still occupied.

TriggerMeaning
A01Admit / visit notification — the episode begins
A02Transfer — the patient moves while the episode continues
A03Discharge / end of visit — the episode closes

Demographic update (A08) is deliberately not in this feed: folding it in would let a name correction silently reopen or close an episode.

PID / PV1 mapping

FieldSourceNotes
MSH-4facility idThe sending facility.
MSH-9triggerStructure and trigger always agree — the receiver dispatches on this.
EVN-1trigger
EVN-2occurredAtWhen the patient moved, not when we sent. A queued or retried message must not rewrite the clinical timeline with our transport's luck.
PID-3patient_identifier (MRN)The identity the other system matches on. Our UUID is meaningless to it.
PID-5family + given name
PID-7date of birth
PID-8administrative sexHL7 table 0001.
PV1-2patient classI inpatient, O outpatient, E emergency (table 0004).
PV1-3assigned locationWhere the patient is now.
PV1-6prior locationRequired on A02. It is what tells the receiver which bed to free; without it that system falls back to its own last-known state and, when the two disagree, empties an occupied bed. A transfer without it is refused rather than sent.
PV1-7attending providerIdentifier and family name, when recorded.
PV1-19visit idThe visit number we own, and the one their messages should quote back.
PV1-44admit datetime
PV1-45discharge datetimePresent on A03.

What actually triggers a message

Nothing calls POST /adt-events in normal operation. The feed is driven by the admission itself, through an SPI in exchange so that core never learns HL7 exists:

AdtServiceImpl.place()/discharge()
→ publishes PatientMovedEvent (core, inside the clinical transaction)
→ PatientMovementNotifier (core, AFTER_COMMIT + REQUIRES_NEW)
→ every PatientMovementHook (exchange SPI)
→ Hl7PatientMovementHook (hl7) → OutboundAdtService

Five properties of that chain are load-bearing:

  • The event carries platform vocabulary, not HL7 codes. PatientMovementType is ADMITTED/TRANSFERRED/DISCHARGED; the mapping to A01/A02/A03 happens inside hl7. Putting trigger codes on the SPI would make every other subscriber speak a protocol it does not use.
  • AFTER_COMMIT, so nothing is announced that did not happen — and REQUIRES_NEW, because in the after-commit phase the committed transaction is still bound to the thread and a plain REQUIRED write joins a transaction that will never commit again. Its work is discarded silently, logged as success. That bug shipped twice already (!297); the annotations are pinned by a test.
  • The prior bed is read before the placement is written. After the move it is gone, and a transfer that cannot say which bed to free is refused rather than sent.
  • A failing subscriber cannot break the clinical act or its siblings. The admission is already committed; an integration's bad day is not grounds to fail it, and each hook is isolated from the next.
  • A patient we cannot read is dropped, not sent with a blank PID. An unidentifiable movement is filed at the far end and can never be reconciled; the absence at least shows up as a gap.

A deployment that integrates with nothing has no hooks on the classpath and the notifier does nothing — the normal case, and it costs one empty loop per movement.

The crash window, and the sweep that closes it (M12-006C)

The chain above ends in an AFTER_COMMIT listener, and the commit and the listener are two different moments. A process that dies between them — a crash, an OOM kill, a pod evicted during a rolling restart — has committed an admission, a transfer or a discharge that nothing will ever announce. Nothing retries it either: the message was never recorded, so it is not PENDING, not FAILED, not in the log at all. It is simply absent, and an absent ADT looks exactly like a quiet ward.

The repair is a durable ledger plus a sweep.

AdtServiceImpl.announce()
→ PatientMovementOutboxService.record() (core, INSIDE the clinical transaction)
→ publishes PatientMovedEvent (delivered AFTER_COMMIT, and this is what can be lost)

AdtReconciliationScheduler (hl7, profile "scheduler", every 5 min)
→ AdtReconciliationService
→ ClinicalClient.searchPatientMovements(since, until, limit) (the ledger)
→ minus IntegrationMessageRepository.findEmittedSourceEventIds(...) (what we already sent)
→ Hl7PatientMovementHook.emit(movement, RECONCILIATION_SWEEP)

patient_movement_outbox is written inside the ADT transaction, so the movement and the evidence of it commit together or roll back together. It is append-only, carries no "emitted" flag — that would need a second transaction and would reopen the window — and is not a second bed-state authority: encounter_location is still the only answer to where a patient is.

The vacated bed is the column it really exists for. It is read before the new placement claims it, so after the move it cannot be recovered from state at all — only inferred from placement history, and an inference that is wrong empties an occupied bed.

Why the sweep cannot double-send

This matters more than whether it re-sends at all. A receiving system has no way to know a second A01 is the same event, so it opens a second admission on a real patient's chart. Three things stand in the way, and only the first is a guarantee:

  1. A unique index on integration_message.source_event_id — one live outbound message per movement, enforced in the database, where the racing transactions actually meet. Partial on voided = false (so a voided message does not block a legitimate re-send) and on source_event_id IS NOT NULL (so inbound ADT and outbound ORM are outside the rule entirely).
  2. The insert is the claim. OutboundAdtService writes the PENDING row and flushes it before building or sending anything, so a movement another emitter already holds is refused before a byte goes down the wire. This is the shape the notification dispatcher gets from SELECT … FOR UPDATE SKIP LOCKED, reached differently because the rows being claimed here do not exist yet. ⚠ The flush is load-bearing: the id is generated in Java, so a plain save would defer the insert to commit and check the constraint after the message had already been sent.
  3. A settle delay (default 2 minutes), so on a healthy system the sweep and the listener are not routinely reaching for the same movement and one of them losing.

What it deliberately does not do

A movement whose message exists but FAILED is left alone. The sweep closes the crash window, not the delivery-retry gap. A failed send may well have reached the receiver and lost only the acknowledgement, so re-emitting it is exactly the duplicate admission above.

A movement with no encounter is skipped, not emitted. The encounter is what makes a movement individually addressable; without it the message could not be recognised again, and the next pass would send it a second time.

Nothing older than the lookback (default 24h) is recovered. An A01 arriving a week late tells a receiving system about an admission that has probably already ended.

Configuration

KeyDefaultWhat it decides
hl7.adt.reconciliation.enabledtrueWhether the sweep runs. On by default: a recovery mechanism that has to be switched on is off in the deployment nobody checked.
hl7.adt.reconciliation.poll-delayPT5MFixed delay, so a slow pass never starts on top of itself.
hl7.adt.reconciliation.initial-delayPT2MA restarted process serves requests first.
hl7.adt.reconciliation.lookbackPT24HThe real bound on how long an outage can last and still be recovered.
hl7.adt.reconciliation.settle-delayPT2MHow recent a movement has to be before the sweep leaves it to the listener.
hl7.adt.reconciliation.batch-size100Movements examined per pass, oldest first.

@EnableScheduling is on Hl7SchedulingConfig, a scanned @Configuration — never on Hl7Application. PlatformApplication excludes the module application classes from its component scan, so an annotation written there is inert in the shipped jar while working perfectly standalone (SCHED-001). A recovery sweep that never starts is indistinguishable from a system with nothing to recover.

The scheduler carries @Profile("scheduler"), so jobs run in their own deployment. The profile decides where; the unique index is what makes a second replica safe.

Inbound ADT (M12-006B)

An ADT^A01/A02/A03 arriving over MLLP is applied to the record by InboundAdtService, dispatched from InboundMessageRecorder.

It calls the same endpoints the patient dashboard calls/clinical/patients/{id}/admit, /transfer, /discharge. Inbound ADT is a second caller of an existing clinical act, never a second implementation, so bed state, encounter placement, attribution and the outbound movement hook all apply identically whether a clerk pressed Admit or an A01 arrived. A private inbound path would be the fourth place bed state is decided, and the one nobody would remember to keep in step.

Identity is reconciled, never guessed

PID-3 (the sending system's MRN) is matched against patient_identifier:

MatchesOutcome
Exactly oneThat patient. The message's demographics are not written over the local record — an A01 is a movement, not a demographic correction, and letting a feeder rewrite a name would make ADT a silent back door into the chart
NoneThe patient is registered through the registration primitive — never a bare person, which is reachable only through patient/staff/provider registration and is what owns identifier and duplicate handling. No account is created: an inbound feed knows nothing about who should be able to log in
More than oneRefused. Two patients sharing an MRN is a data problem a human must resolve; picking one attaches a stranger's admission to a chart and nothing downstream would question it

What is refused, and why it is REJECTED rather than FAILED

No PID-3, no PID-5 family name on an unknown patient, an ambiguous identifier, or an unsupported trigger. Retrying an unchanged message fails identically, so these need a person and not a queue.

The ACK is not conditional on the message being applied. The sender asked whether we received it, and we did — the integration row says so. Refusing the ACK would make the sender resend a message that will fail identically, forever.

Only A01/A02/A03 are dispatched. A08 is a demographic update and A11/A13 are cancellations, each with its own semantics; treating an unknown trigger as an admission is how a cancelled visit becomes a live one.

A bed code we cannot resolve does not lose the movement. PV1-3 in the sender's own vocabulary is the normal case; the admission is applied without a care location rather than refused.

Lab order and result messaging (M12-007B)

The mirror of the imaging chain: ORM in, ORU out — an external orderer places lab work with this platform as the filler and receives the released result back.

Inbound ORM^O01 enters the generic order rail, never a private lab ingest. The handler resolves the patient (PID-3, the inbound-ADT matching rules — but an unknown patient is refused, never registered: send the ADT first), resolves the orderable through the concept mappings (unmapped = REJECTED naming the code), and calls POST /api/v1/clinical/orders — so the order gets concept validation, LabOrderTypeProcessor accessions it through M20-003's single method, the charge fires and every OrderStatusChangedEvent subscriber sees it, exactly as a CPOE order. NW is idempotent on ORC-2 (a re-placed order reports the one we hold), CA cancels by the same number, and XO is refused loudly until M20 defines external amendment of an accessioned order. The placer number and sending system are stamped onto the order (external_placer_order_number/external_source_system, clinical/030) — placement-only provenance, echoed back on the ORU.

Outbound ORU^R01 rides M20-005's release seam (LabOruReleaseHook, one more AFTER_COMMIT subscriber — a lab with no HL7 still releases results) with the imaging outbound posture: recorded before attempted, PENDING when no endpoint is configured, REJECTED when unbuildable. One release = one message: source_event_id carries the labResultId under the outbox's unique index. OBR-2 echoes the orderer's placer number (best-effort; OBR-3's accession always correlates), and OBX-11 distinguishes F from C so a corrected result never reads as a second final. OBX-6 (units) is deliberately absent until the release payload carries UCUM units — inventing them is the TERM-014 failure one field over.

Codes leave under TERM-014's v2 half (Hl7ConceptCodingFactory): a foreign code only from a live SAME_AS mapping whose source resolves through a closed 0396 table (LN/SCT/I10); everything else travels as ours under 99UHP (table 0396's reserved local namespace), standard-first with the local code as the alternate triplet.

The dispatcher debt. M12-007A shipped OruMessageHandler and nothing ever invoked it — the recorder dispatched only ADT, so every inbound result would have sat PENDING forever. The recorder now routes ADT*/ORM*/ORU* through one dispatcher; unknown types are recorded and left alone.

The integration message log (M12-009)

integration_message is written by three paths — outbound ADT, inbound ADT and outbound imaging orders — and until M12-009 nothing could read it. Every REJECTED message and every unconfigured channel sitting PENDING was invisible. A log nobody can see is not a log; it is disk usage.

EndpointAnswers
GET /api/v1/hl7/integration-messagesThe log, newest first, filterable by direction, status, since
GET /api/v1/hl7/integration-messages/connectivityHow the interfaces are doing over a window (defaults to 24h)

What the statuses mean, and why they are not collapsed

StatusMeaningWho acts
PENDINGRecorded, not yet delivered — including an unconfigured channel, which is a deployment that is not finished rather than a failurewhoever finishes the deployment
PROCESSEDDelivered or appliednobody
FAILEDDelivery failed; retrying may worka retry
REJECTEDRefused — retrying unchanged fails identicallya person

Burying PENDING among failures is how a real failure gets lost in the setup gaps, and folding REJECTED into FAILED is how something that needs a human sits in a retry queue forever.

The connectivity score

processed / total, with the counts beside it. Two properties are deliberate:

  • An empty window scores null, not 100%. A silent feed and a flawless one are opposite situations; rendering both green hides the one worth waking someone for.
  • oldestUnresolvedAt is reported, because a rising age there is the earliest visible sign a feed has stopped — the totals still look healthy while the backlog quietly ages.

No endpoint returns a message body. An HL7 message is PHI in its entirety, the log never stored one, and this API cannot surface what does not exist. Type, control id, status, attempts and a PHI-free failure reason are what an operator needs to ring the sending system.

There is no retry endpoint, deliberately. With no stored body there is nothing to resend — and storing one would break the PHI rule the log was built around. A stuck outbound message is re-emitted from its source record — the reconciliation sweep, which shipped as M12-006C and is described above.

API

POST /api/v1/hl7/outbound/adt-events queues a movement; POST /api/v1/hl7/outbound/imaging-orders queues an imaging order. Both answer 202 with the message's id in the integration log. Callers use Hl7OutboundClient in exchange, never an import of this module.

  • Imaging — the RIS mode that decides whether orders leave at all.
  • Clinical — visits, whose lifecycle the ADT feed reports.

Why it is this way

The insert is the claim (M12-006C). A PENDING row is written before the send is attempted, so the database — not memory — records that this message is spoken for. That is what makes a double-send impossible across restarts and concurrent workers.

The MLLP listener binds only when configured. An interface that silently listens on a default port is an interface nobody knows is exposed.

Message bodies are not stored. The log records that a message happened, its identifiers and its outcome; keeping the raw HL7 would keep a copy of clinical data in an operational log.

Traps

saveAndFlush, not save — this is load-bearingOutboundAdtService. Deferring the flush lets a second worker miss the claim and send the same message twice. It looks like a gratuitous flush and is the opposite.

Reconciliation cannot double-send, and that matters more than whether it re-sends at allAdtReconciliationService. Judge any change here by whether the claim still holds under two workers, not by whether it eventually retries.

An error ACK is returned unchanged, so the sender learns we refused itMllpConfig. Swallowing it leaves the sending system believing a message was accepted.

The module's permissions are read/write on the log, deliberately narrowHl7ModuleDescriptor. Nothing there grants the ability to send a message; queueing outbound traffic is a separate, guarded act.

ADT^A40 — patient merge notification (M37-008)

Lab and imaging systems keep their own patient index. Without an A40 they never learn of a merge, and a result comes back against a record that no longer exists — filed under a patient nobody is treating, or matched to the wrong chart. The merge (M37-004) is correct inside this platform and invisible outside it.

The trigger is A40; the structure is ADT_A39. That is not a mistake to fix. HL7 v2.5 gives the A39/A40/A41/A42 family one shared structure, and the receiving parser dispatches on MSH-9, which carries the A40 trigger. (AdtMessageBuilder warns that a message built inside the wrong structure is filed as the wrong event — here the standard itself pairs them this way.)

MRG-1 is the entire point. PID-3 says who the patient is now; MRG-1 says which identifier the receiver should retire and re-point. An A40 with an empty MRG names one patient twice and instructs nothing, so it is refused rather than sent — exactly as an A02 with no prior location is.

EVN-2 is when the merge happened, not when we send. A message delayed by a queue or a retry must still say when the records actually became one.

Medical record numbers, never our UUIDs. The receiving system has never seen our ids; matching on one is matching on nothing.

OutboundPatientMergeEventDto is deliberately not an OutboundAdtEventDto: that DTO is movement-shaped (visit, ward, prior location) and a merge has none of those. Adding MERGE to AdtEventType would also put a value into every switch over that enum that no other path can handle.