Skip to content
LambogeniusDocs

HL7 Edge Gateway

One ADT feed, three shapes

One route, three destinations, each with a transform: HL7 with the lab system's own codes, JSON for a patient portal, and de-identified HL7 for research.

Runs onWindows · Linux · macOS · DockerFree developer license for test traffic. Production traffic needs a paid license.

What it does

MEDITECH sends one ADT feed. Three systems want it, each in its own shape. The route adt-out has three destinations, and each one has a transform:

DestinationTransformWhat it gets
lis, MLLPtransforms/lis.toml: rules and a CSV code tableHL7 addressed to the LIS, the MRN copied to PID-2, a ZLB segment for emergency registrations, PV1-2 mapped to the LIS's own codes (a code the table doesn't have is an error), no NK1
patient-api, HTTPtransforms/patient-api.toml with a Liquid templateJSON, sent as application/json: the event, patient, visit and allergies
research, HTTPinline in gateway.tomlHL7 with the MRN and visit number replaced by salted SHA-256 hashes; name, address, phone and SSN cleared; year of birth only; no NK1

The queue keeps each event as MEDITECH sent it, and a transform runs at each delivery. So when the LIS's table is missing a code, that event waits in the LIS's dead letters only; once the table has the code, a requeue sends it transformed with the new entry. The portal and the registry are never held up. You need the gateway image 0.6.0 or later.

The sample is in 13-one-message-three-shapes in the examples repository.

Diagrams

Architecture diagram of the 13-one-message-three-shapes sample
Architecture. What runs where, and how the systems connect. Source: architecture.mmd
Data flow diagram of the 13-one-message-three-shapes sample
Data flow. What happens to a message at each step, including the failure paths. Source: data-flow.mmd
Sequence diagram of the 13-one-message-three-shapes sample
Sequence. One run of the sample, message by message. Source: sequence.mmd

The configuration

13-one-message-three-shapes/gateway.toml — one route, three destinations, each with a transform

# Sample 13: one ADT feed from MEDITECH, and each system gets it in its own shape (HL7 Edge Gateway 0.6+).
#   lis          the lab system, over MLLP: HL7 with its own codes             transforms/lis.toml
#   patient-api  a patient portal's REST API: JSON from a Liquid template       transforms/patient-api.toml
#   research     a research registry: HL7, de-identified                        inline, below
# Secrets come from .env.

[admin]
listen = "0.0.0.0:8080"
token = "${HL7EDGE_ADMIN_TOKEN}"
allow_payload_view = true        # the console's message viewer, with "as received" and "as sent"

[store]
backend = "embedded"
path = "/var/lib/hl7-edge-gateway"

[[route]]
name = "adt-out"
mode = "store_and_forward"
source = { type = "mllp", listen = "0.0.0.0:7890" }
store_and_forward = { on_reject = "dead_letter", retry = { initial = "1s", max = "5s" } }

[[route.destinations]]
name = "lis"
type = "mllp"
address = "lis:6661"
transform = "transforms/lis.toml"

[[route.destinations]]
name = "patient-api"
type = "http"
url = "http://systems:8000/patients"
headers = { Authorization = "Bearer ${PORTAL_API_TOKEN}" }
transform = "transforms/patient-api.toml"   # sent as application/json

[[route.destinations]]
name = "research"
type = "http"
url = "http://systems:8000/research"
transform = { step = [
  { hash = { fields = ["PID-3.1", "PV1-19"], salt = "${RESEARCH_SALT}" } },  # the same patient, the same hash
  { clear = ["PID-5", "PID-11", "PID-13", "PID-19"] },                     # name, address, phone, SSN
  { date = { field = "PID-7", to = "%Y" } },                               # year of birth only
  { delete = { segment = "NK1" } },                                        # next of kin
] }

13-one-message-three-shapes/transforms/lis.toml — the LIS's rules, run in order

# What the lab system (LIS) receives: HL7 still, addressed to it, with its own patient class codes, the MRN
# where it looks for it, an ED flag, and no next of kin. Steps run in order, each on the message as the steps
# before it left it. Try it without sending anything:
#   docker compose run --rm tool transform -t /sample/transforms/lis.toml --diff /messages/adt-a04-emergency.hl7

[[step]]
set = { "MSH-5" = "LIS", "MSH-6" = "LAB" }

# The LIS reads the MRN from PID-2.
[[step]]
copy = { from = "PID-3[1].1", to = "PID-2" }

# Emergency registrations: the LIS wants the ED flagged in a Z-segment. This step comes before the map
# below, so PV1-2 is still MEDITECH's "E" here.
[[step]]
when = { message_type = ["ADT^A04"], fields = { "PV1-2" = ["E"] } }
add = { segment = "ZLB", after = "PV1", fields = { "ZLB-1" = "ED", "ZLB-2" = "STAT" } }

# The LIS has its own patient classes. A code it doesn't have is an error, not a guess:
# that event goes to the LIS's dead letters until the table has it.
[[step]]
map = { field = "PV1-2", table = "patient_class", missing = "fail" }

[[step]]
delete = { segment = "NK1" }

[tables]
patient_class = "tables/patient-class.csv"

13-one-message-three-shapes/transforms/tables/patient-class.csv — MEDITECH's patient class, and the LIS's

code,value
# MEDITECH's PV1-2 patient class, and the LIS's code for it.
I,INPT
E,EMER
O,OUTPT
P,PREADM
R,RECUR

13-one-message-three-shapes/transforms/patient-api.toml

# What the patient portal's API receives: JSON, made by the Liquid template patient-api.liquid.
#   docker compose run --rm tool transform -t /sample/transforms/patient-api.toml /messages/adt-a01-inpatient.hl7

[[step]]
template = "patient-api.liquid"
output = "json"                  # checked before it's sent, and sent as application/json

[tables.patient_class]
I = "inpatient"
E = "emergency"
O = "outpatient"
"*" = "other"

[tables.event]
"ADT^A01" = "admitted"
"ADT^A03" = "discharged"
"ADT^A04" = "registered"
"ADT^A08" = "updated"
"*" = "other"

13-one-message-three-shapes/transforms/patient-api.liquid — the portal's JSON

{%- assign allergies = msg | segments: "AL1" -%}
{%- assign room = msg | field: "PV1-3.2" -%}
{
  "event": {{ message_type | lookup: "event" | json }},
  "messageId": {{ control_id | json }},
  "patient": {
    "mrn": {{ msg | field: "PID-3[1].1" | json }},
    "name": { "family": {{ msg | field: "PID-5.1" | json }}, "given": {{ msg | field: "PID-5.2" | json }} },
    "birthDate": {{ msg | field: "PID-7" | iso_date | json }},
    "sex": {{ msg | field: "PID-8" | json }}
  },
  "visit": {
    "number": {{ msg | field: "PV1-19" | json }},
    "class": {{ msg | field: "PV1-2" | lookup: "patient_class" | json }},
    "location": {{ msg | field: "PV1-3.1" | append: " " | append: room | strip | json }},
    "admitted": {{ msg | field: "PV1-44" | iso_date | json }}
  },
  "allergies": [{% for a in allergies %}{{ a | field: "AL1-3.2" | json }}{% unless forloop.last %}, {% endunless %}{% endfor %}],
  "receivedAt": {{ received | json }}
}

Step by step

  1. 1
    Start it.
    cd 13-one-message-three-shapes
    cp .env.example .env            # add your license
    docker compose up -d

    In the console at http://localhost:8080, the route adt-out has a row for each destination, with its transform.

  2. 2
    MEDITECH sends an inpatient admission and an emergency registration.
    docker compose run --rm send --to gateway:7890 /messages/adt-a01-inpatient.hl7 /messages/adt-a04-emergency.hl7
    
    docker compose logs --no-log-prefix lis
    lis: AA ADT-1302
    lis:   MSH|^~\&|MEDITECH|GENERAL_HOSPITAL|LIS|LAB|20261021091500||ADT^A04^ADT_A01|ADT-1302|T|2.5.1
    lis:   EVN|A04|20261021091500
    lis:   PID|1|200302|200302^^^GENERAL_HOSPITAL^MR||LINDQVIST^ERIK||19921102|M|||7 Mill St^^Lakeside^ON^K0K 1A0||(555)010-4455||||||999-01-2302
    lis:   PV1|1|EMER|ED^BAY3^1||||1234^HALE^SIMON|||MED|||||||||V5002|||||||||||||||||||||||||20261021091200
    lis:   ZLB|ED|STAT
    
    curl http://localhost:8000/received
      "system": "patient-api", "content_type": "application/json",
      "body": { "event": "admitted", "messageId": "ADT-1301",
        "patient": { "mrn": "200301", "name": { "family": "OKAFOR", "given": "AMARA" }, "birthDate": "1985-03-14", "sex": "F" },
        "visit": { "number": "V5001", "class": "inpatient", "location": "4W 401", "admitted": "2026-10-21T08:25:00" },
        "allergies": ["Penicillin", "Cashew nuts"], … }
    
      "system": "research",
      "body": [ …, "PID|1||28dbd8dba5969e19004e1bffaa1ba78598096b48ed73eb552c7710996e64f56c^^^GENERAL_HOSPITAL^MR||||1985|F|||||||||||", … ]

    The LIS gets PV1|1|EMER|… (and INPT for the admission), the MRN in PID-2, the ED flag, and no next of kin. The portal gets JSON. The registry gets HL7 whose MRN is a hash: the same patient always gets the same hash, so records still link up, but the MRN can't be read back.

    Route adt-out with rows for lis (lis.toml: 5 rules), patient-api (template patient-api.liquid → json) and research (inline: 4 rules); lis has one dead letter
    Each destination shows its transform. This is after step 3: the LIS has one dead letter, the others have every event.
  3. 3
    A patient class the LIS doesn't have.
    docker compose run --rm send --to gateway:7890 /messages/adt-a08-boarder.hl7
        1       4 ms  MSA|AA|ADT-1303

    MEDITECH gets AA: the event is stored for all three. The portal ("class": "other", from the template table's "*" entry) and the registry get it. The LIS's map has missing = "fail", so ADT-1303 goes to the LIS's dead letters, with the failing step as the reason. Select the lis row, then Dead letters, and open it: As received shows MEDITECH's event, and As sent to lis shows why it can't be sent.

    Dead letter ADT-1303 as received: reason transform failed: step 4 (map PV1-2): PV1-2 is B, which table patient_class doesn't have; the HL7 shows PV1|1|B
    The dead letter holds the event as MEDITECH sent it.
    The same dead letter as sent to lis: the transform fails on this message, so its delivery is rejected: step 4 (map PV1-2)
    As sent to lis: the transform runs again, and says which step fails.
  4. 4
    Add the code, restart, requeue.
    echo "B,BOARDER" >> transforms/tables/patient-class.csv
    docker compose run --rm tool check -c /sample/gateway.toml
    docker compose restart gateway

    Requeue ADT-1303 from the LIS's dead letters in the console. The LIS gets it with PV1|1|BOARDER|…: the dead letter held the original event, and the transform ran again with the new table.

  5. 5
    See what a transform does, without sending anything.

    The transform command runs a destination's transform on message files. With --diff, it shows the segments it changed:

    docker compose run --rm tool transform -c /sample/gateway.toml --destination lis --diff /messages/adt-a04-emergency.hl7
    transform OK: lis.toml: 5 rules
    
    /messages/adt-a04-emergency.hl7  ADT-1302 ADT^A04
    - MSH|^~\&|MEDITECH|GENERAL_HOSPITAL|HIS|GENERAL_HOSPITAL|20261021091500||ADT^A04^ADT_A01|ADT-1302|T|2.5.1
    + MSH|^~\&|MEDITECH|GENERAL_HOSPITAL|LIS|LAB|20261021091500||ADT^A04^ADT_A01|ADT-1302|T|2.5.1
      EVN|A04|20261021091500
    - PID|1||200302^^^GENERAL_HOSPITAL^MR||LINDQVIST^ERIK||19921102|M|||7 Mill St^^Lakeside^ON^K0K 1A0||(555)010-4455||||||999-01-2302
    + PID|1|200302|200302^^^GENERAL_HOSPITAL^MR||LINDQVIST^ERIK||19921102|M|||7 Mill St^^Lakeside^ON^K0K 1A0||(555)010-4455||||||999-01-2302
    - NK1|1|LINDQVIST^SARA|MTH||(555)010-4456
    - PV1|1|E|ED^BAY3^1||||1234^HALE^SIMON|||MED|||||||||V5002|||||||||||||||||||||||||20261021091200
    + PV1|1|EMER|ED^BAY3^1||||1234^HALE^SIMON|||MED|||||||||V5002|||||||||||||||||||||||||20261021091200
    + ZLB|ED|STAT

    -t /sample/transforms/patient-api.toml runs a transform file on its own and prints the JSON. It exits with 3 when a transform fails on a message, so a build can test a transform change against sample messages. The console's Transform tester does the same for pasted messages:

    Transform tester with destination adt-out → lis and ADT-1302 pasted: each changed segment's old and new version, NK1 removed and ZLB added
    The Transform tester: nothing is sent or stored.
Before going live

Keep RESEARCH_SALT secret, and the same: changing it changes every hash. Review what else your messages carry (OBX notes, NTE, Z-segments) before sending them outside the hospital. Run each transform on a day of real messages with hl7-edge-gateway transform -c to find codes your tables don't have yet.

Or run every step with ./demo.sh (Linux, macOS, Git Bash) or ./demo.ps1 (PowerShell). Clean up with docker compose down -v. More in Transforms.