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:
| Direction | Messages | Ticket |
|---|---|---|
| Outbound | ORM^O01 imaging orders (new / modified / cancelled) | M12-007A |
| Outbound | ADT^A01/A02/A03 patient movements | M12-006 |
| Inbound | ORU^R01 results, filed as concept-coded observations | M12-007A |
Data model & ownership
| Table | Purpose |
|---|---|
integration_message | Every 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
202from 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 thoseFAILEDburies 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.
| Trigger | Meaning |
|---|---|
A01 | Admit / visit notification — the episode begins |
A02 | Transfer — the patient moves while the episode continues |
A03 | Discharge / 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
| Field | Source | Notes |
|---|---|---|
MSH-4 | facility id | The sending facility. |
MSH-9 | trigger | Structure and trigger always agree — the receiver dispatches on this. |
EVN-1 | trigger | |
EVN-2 | occurredAt | When the patient moved, not when we sent. A queued or retried message must not rewrite the clinical timeline with our transport's luck. |
PID-3 | patient_identifier (MRN) | The identity the other system matches on. Our UUID is meaningless to it. |
PID-5 | family + given name | |
PID-7 | date of birth | |
PID-8 | administrative sex | HL7 table 0001. |
PV1-2 | patient class | I inpatient, O outpatient, E emergency (table 0004). |
PV1-3 | assigned location | Where the patient is now. |
PV1-6 | prior location | Required 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-7 | attending provider | Identifier and family name, when recorded. |
PV1-19 | visit id | The visit number we own, and the one their messages should quote back. |
PV1-44 | admit datetime | |
PV1-45 | discharge datetime | Present 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.
PatientMovementTypeisADMITTED/TRANSFERRED/DISCHARGED; the mapping toA01/A02/A03happens insidehl7. 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 — andREQUIRES_NEW, because in the after-commit phase the committed transaction is still bound to the thread and a plainREQUIREDwrite 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:
- 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 onvoided = false(so a voided message does not block a legitimate re-send) and onsource_event_id IS NOT NULL(so inbound ADT and outbound ORM are outside the rule entirely). - The insert is the claim.
OutboundAdtServicewrites thePENDINGrow 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 fromSELECT … 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 plainsavewould defer the insert to commit and check the constraint after the message had already been sent. - 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
| Key | Default | What it decides |
|---|---|---|
hl7.adt.reconciliation.enabled | true | Whether 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-delay | PT5M | Fixed delay, so a slow pass never starts on top of itself. |
hl7.adt.reconciliation.initial-delay | PT2M | A restarted process serves requests first. |
hl7.adt.reconciliation.lookback | PT24H | The real bound on how long an outage can last and still be recovered. |
hl7.adt.reconciliation.settle-delay | PT2M | How recent a movement has to be before the sweep leaves it to the listener. |
hl7.adt.reconciliation.batch-size | 100 | Movements 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:
| Matches | Outcome |
|---|---|
| Exactly one | That 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 |
| None | The 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 one | Refused. 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.
| Endpoint | Answers |
|---|---|
GET /api/v1/hl7/integration-messages | The log, newest first, filterable by direction, status, since |
GET /api/v1/hl7/integration-messages/connectivity | How the interfaces are doing over a window (defaults to 24h) |
What the statuses mean, and why they are not collapsed
| Status | Meaning | Who acts |
|---|---|---|
PENDING | Recorded, not yet delivered — including an unconfigured channel, which is a deployment that is not finished rather than a failure | whoever finishes the deployment |
PROCESSED | Delivered or applied | nobody |
FAILED | Delivery failed; retrying may work | a retry |
REJECTED | Refused — retrying unchanged fails identically | a 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. oldestUnresolvedAtis 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.
Related features
- 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-bearing — OutboundAdtService. 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 all —
AdtReconciliationService. 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 it — MllpConfig.
Swallowing it leaves the sending system believing a message was accepted.
⚠ The module's permissions are read/write on the log, deliberately narrow —
Hl7ModuleDescriptor. 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.