Skip to content
LambogeniusDocs

HL7 Edge Gateway

One ADT feed, three systems

MEDITECH and a partner clinic into one route, out to a filtered lab API, a downstream HIS and an SFTP archive.

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

What it does

ADT events come from MEDITECH over MLLP, and from a partner clinic's registration system over HTTP. Three systems need them, so the route adt-feed has two sources and three destinations, each with a queue of its own:

DestinationGetsThrough
labADT^A01, A04 and A08 for inpatients and emergency patients (PV1-2 is I or E)the lab's REST API, with a bearer token
hisevery eventMLLP; a patient the HIS doesn't know (AR) goes to this destination's dead letters
archiveevery eventSFTP, as HL7 batch files of up to 5 events, closed after 20 s at most

When the lab API is down, the HIS and the archive keep getting events, and the lab catches up when it is back. You need the gateway image 0.5.0 or later.

The sample is in 11-one-feed-many-systems in the examples repository.

Diagrams

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

The configuration

11-one-feed-many-systems/gateway.toml

# Sample 11: one ADT feed to three systems, from two senders
#
#   Sources        MEDITECH sends ADT over MLLP (:7890); a partner clinic's registration system posts
#                  ADT over HTTP (:8443, with its API key). Both feed the same route, in arrival order.
#   Destinations   Each has a queue of its own, so one that is down holds up no other.
#     lab          The lab's REST API wants only inpatient and emergency admissions: ADT^A01, A04 and
#                  A08 with patient class (PV1-2) I or E.
#     his          The downstream HIS gets every ADT event over MLLP.
#     archive      Every event also goes to the archive's SFTP server, in HL7 batch files.
#
# Store-and-forward: the senders get AA once the message is stored for every destination it goes to.

node_id = "gateway-1"

[admin]
listen = "0.0.0.0:8080"
token = "${HL7EDGE_ADMIN_TOKEN}"
allow_payload_view = true

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

[[route]]
name = "adt-feed"
mode = "store_and_forward"
sources = [
  { type = "mllp", listen = "0.0.0.0:7890" },
  # In production put HTTPS (tls = { cert = …, key = … }) in front of the partner.
  { type = "http", listen = "0.0.0.0:8443", path = "/hl7/adt", auth = { type = "api_key", keys = { lakeside-clinic = "${PARTNER_API_KEY}" } } },
]

[route.store_and_forward]
retry = { initial = "2s", max = "30s", multiplier = 2.0 }

[[route.destinations]]
name = "lab"
type = "http"
url = "http://lab-api:8000/hl7/admissions"
headers = { Authorization = "Bearer ${LAB_API_TOKEN}" }
# Every field named must match; the values of one field are alternatives. PV1-2 is the patient class.
filter = { message_type = ["ADT^A01", "ADT^A04", "ADT^A08"], fields = { "PV1-2" = ["I", "E"] } }

[[route.destinations]]
name = "his"
type = "mllp"
address = "his:6661"
# A patient the HIS doesn't know (AR) goes to this destination's dead letters; the others are unaffected.
store_and_forward = { on_reject = "dead_letter", retry = { initial = "2s", max = "30s", multiplier = 2.0 } }

[[route.destinations]]
name = "archive"
type = "sftp"
host = "sftp"
username = "hospital"
private_key = "/keys/gateway_ed25519"
known_hosts = "/keys/known_hosts"
directory = "inbox"
batch_format = "hl7_batch"
file_name = "adt_{timestamp}.hl7"
batch = { max_messages = 5, max_wait = "20s" }

11-one-feed-many-systems/compose.yaml — the lab API is sample 01's, and the SFTP keys are made as in sample 10

# Sample 11: one ADT feed (MEDITECH and a partner clinic) to three systems: a lab API, a downstream
# HIS and an SFTP archive.
#   docker compose up -d
#   docker compose run --rm send …       send HL7 files to the gateway over MLLP (as MEDITECH)
name: hl7edge-sample-11

services:
  gateway:
    image: lambogenius/hl7-edge-gateway:${GATEWAY_VERSION:-0.5.0}
    env_file: .env
    ports:
      - "7890:7890"   # ADT from MEDITECH (MLLP)
      - "8443:8443"   # ADT from the partner clinic (HTTP POST /hl7/adt)
      - "8080:8080"   # admin console
    volumes:
      - ./gateway.toml:/etc/hl7-edge-gateway/hl7-edge-gateway.toml:ro
      - keys:/keys:ro
      - queue:/var/lib/hl7-edge-gateway
    depends_on: [lab-api, his, sftp]

  # The lab's REST API (the same stand-in as sample 01): POST /control/down and /control/up switch an outage.
  lab-api:
    image: python:3.12-slim
    command: ["python", "-u", "/app/app.py"]
    env_file: .env
    ports: ["8000:8000"]
    volumes: ["./lab-api:/app:ro"]

  # The downstream HIS, over MLLP.
  his:
    image: python:3.12-slim
    command: ["python", "-u", "/app/receiver.py"]
    volumes: ["./his:/app:ro"]

  # The archive's SFTP server, and the keys the gateway signs in with (the same setup as sample 10).
  sftp:
    image: atmoz/sftp:alpine
    command: ["hospital:${LAB_SFTP_PASSWORD}:1001:100:inbox"]
    ports: ["2222:22"]
    volumes:
      - keys:/keys:ro
      - ./sftp/install-keys.sh:/etc/sftp.d/install-keys.sh:ro
    depends_on:
      keys: { condition: service_completed_successfully }
  keys:
    image: alpine:3.20
    command: ["sh", "/scripts/make-keys.sh"]
    volumes:
      - keys:/keys
      - ./sftp:/scripts:ro

  # Tools, not started by `up`.
  send:
    image: lambogenius/hl7-edge-gateway:${GATEWAY_VERSION:-0.5.0}
    entrypoint: ["hl7-edge-gateway", "send"]
    volumes: ["./messages:/messages:ro"]
    profiles: ["tools"]

volumes:
  keys:
  queue:

Step by step

  1. 1
    Start it.
    cd 11-one-feed-many-systems
    cp .env.example .env            # add your license
    docker compose up -d

    In the console at http://localhost:8080, the route adt-feed shows its two sources, and a row for each destination with its filter, state and queue.

  2. 2
    MEDITECH sends four ADT events.
    docker compose run --rm send --to gateway:7890 /messages/adt-a01-inpatient.hl7 /messages/adt-a04-emergency.hl7 \
      /messages/adt-a08-outpatient.hl7 /messages/adt-a03-discharge.hl7
    docker compose logs his
    his adt: AA ADT-9001 ADT^A01^ADT_A01 patient 100131 (filed)
    his adt: AA ADT-9002 ADT^A04^ADT_A01 patient 100132 (filed)
    his adt: AA ADT-9003 ADT^A08^ADT_A01 patient 100133 (filed)
    his adt: AA ADT-9004 ADT^A03^ADT_A01 patient 100131 (filed)
    curl http://localhost:8000/received        # the lab: ADT-9001 and ADT-9002 only

    The HIS gets all four, in order. The lab gets the inpatient A01 and the emergency A04: ADT-9003 is an outpatient (PV1-2 is O), and ADT-9004 is an A03.

  3. 3
    The partner clinic posts an event over HTTP.
    curl -H "X-API-Key: <PARTNER_API_KEY from .env>" -H "Content-Type: x-application/hl7-v2+er7" \
      --data-binary @messages/adt-a04-partner.hl7 http://localhost:8443/hl7/adt
    MSH|^~\&|HL7EDGE|GATEWAY|LAKESIDE_REG|LAKESIDE_CLINIC|…||ACK^A04|…|T|2.5.1
    MSA|AA|ADT-9005

    ADT-9005 joins the same queues, after MEDITECH's events: the HIS and the lab get it, and it is the fifth event in the archive's batch file, which closes it.

    Route adt-feed with MLLP and HTTP sources, and rows for the destinations lab (with its filter), his and archive
    One route, two sources, three destinations: each destination shows its filter, state and queue.
  4. 4
    The lab API goes down.
    curl -X POST http://localhost:8000/control/down
    docker compose run --rm send --to gateway:7890 /messages/adt-a01-during-outage.hl7

    The HIS and the archive get ADT-9006 at once; it waits in the lab's queue only, and the lab retries.

    lab retrying x5 with 1 queued and HTTP 503, while his and archive are idle with 6 delivered each
    The lab is down: ADT-9006 waits in its queue, while the HIS and the archive already have it.

    curl -X POST http://localhost:8000/control/up, and the lab catches up.

  5. 5
    A patient the HIS doesn't know.

    Send adt-a08-unknown-patient.hl7. The HIS answers AR, so ADT-9007 goes to the HIS's dead letters; the lab and the archive get it as usual. Select the his row to see it:

    adt-feed to his: dead letter ADT-9007, rejected: AR ACK: patient not found: 200700
    Dead letters per destination: ADT-9007 is held for the HIS only.
  6. 6
    See why a destination gets an event, or not.

    The filter command checks the configuration's filters and shows, condition by condition, where each message would go. It sends nothing, and doesn't need .env:

    docker compose run --rm -v ./gateway.toml:/gateway.toml:ro --entrypoint hl7-edge-gateway send \
      filter -c /gateway.toml /messages/adt-a01-inpatient.hl7 /messages/adt-a08-outpatient.hl7
    
    /messages/adt-a08-outpatient.hl7  ADT-9003 ADT^A08  route adt-feed: to his, archive
      lab              doesn't get it
        yes  message_type ADT^A01|ADT^A04|ADT^A08   found ADT^A08
        no   PV1-2 I|E                              found O
      his              gets it (no filter)
      archive          gets it (no filter)

    The console's Filter tester does the same for pasted messages. See Filters.

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