MLLP runtime architecture
The five layers of Glion's MLLP server — listener, framer, parser, dispatcher, middleware — and the boundary between framing-level and handler-level errors.
MLLP gives HL7v2 a transport: a few framing bytes around a message, sent over a long-lived TCP connection. The protocol itself is small. Almost everything that makes an MLLP server hard — back-pressure, connection lifecycle, partial frames, ack construction, error handling — lives above the framing.
Glion's Mllp runtime decomposes those concerns into five layers: listener, framer, parser, dispatcher, and middleware. Each layer has one responsibility and one interface to the next; a failure in one is contained, observable, and replaceable.
The layers at a glance
- Listener — accepts TCP connections, terminates TLS if configured, manages the connection pool.
- Framer — reads bytes, recognizes MLLP frames, and emits one message buffer per frame.
- Parser — turns the buffer into an AST.
- Dispatcher — selects the handler for the message based on its trigger event.
- Middleware and handler — the user's code, plus any cross-cutting concerns wrapped around it.
Listener
The listener accepts inbound TCP connections, applies TLS when configured, and feeds raw bytes to the framer. It owns the connection lifecycle: idle timeouts, graceful shutdown, the per-connection limits that bound a misbehaving partner.
The listener is a named component, not a constructor option. Integration teams configure binding to a specific interface, client-certificate requirements, connection-event logging, and in-flight drain on SIGTERM directly against it.
Back-pressure lives here. A long-running handler can stall a connection; ten of them can stall the server. The listener suspends reads on connections whose handlers have not drained, rather than buffering. Downstream layers assume the upstream has rate-limited them.
Framer
The framer reads bytes from a connection and emits message buffers, one per MLLP frame. The protocol is small; the failure modes are not. A partial frame can arrive across multiple TCP reads. A misframed message — missing a start byte, missing an end byte, containing an embedded start byte — needs a defined recovery policy. A connection sending garbage indefinitely needs to be cut off rather than buffered forever.
Glion's framer is strict by default. An unframed byte before the start of a message is an error: the connection is logged, and the framer either skips to the next start byte or closes the connection per policy. The default is to close — a misframed sender will not produce a valid message later in the same stream. The skip-to-next mode is available for a recovery-mode listener attached to a queue replay.
Framing and parsing are separate. The framer's output is a byte buffer; the parser turns it into an AST. An integration test for the framer uses any HL7v2 string, valid or not; an integration test for the parser ignores framing entirely.
Parser and dispatcher
The parser is a unified processor with the route's plugins attached. Whatever plugins are configured — escape decoding, validation, datatype enrichment — run between the framer and the dispatcher. By the time the dispatcher sees the message, the AST is in whatever shape the route configured.
The dispatcher selects a handler based on the trigger event declared in the message header. Handlers register with app.on("ADT^A01", ...), a wildcard app.on("ADT^*", ...), or a catch-all app.on("*", ...). Routes are matched in registration order — the first matching route wins, so register specific routes before catch-alls. Selection is deterministic: a route's handlers are listable from the runtime, surfaced in glion dev's UI, and printable from the CLI.
A message that matches no handler is a defined event. The default response is an AR acknowledgment with a diagnostic identifying the unrouted trigger event; the behavior is configurable per route. The default is strict: silent drops in an MLLP listener are a class of bug that rarely surfaces in time.
Middleware
The handler is the user's business logic. Everything that wraps it — logging, metrics, authentication, tenant resolution, rate limiting, automatic ack construction — is middleware. The middleware model is the same app.use(fn) shape that web frameworks use; cross-cutting concerns are expressed once.
Middleware runs in registration order on the way in, and reverse order on the way out. A logging middleware registered first sees every message before validation has rejected it; a metrics middleware registered last sees only messages that reached a handler. Order is part of the route's configuration.
The most-used middleware is ackMiddleware() from @glion/mllp-ack. It constructs the ack after the handler returns, using the success or failure of the handler — and any diagnostics on the vfile — to decide between AA, AE, and AR. A handler that writes nothing is the common shape; the middleware translates handler outcomes into wire responses.
Middleware can short-circuit. A rate-limiter that decides the partner has exceeded its budget responds with AR directly and skips both validation and the handler. Short-circuiting is explicit: middleware calls ctx.respond(ack) and returns rather than awaiting next().
Handlers
A handler is a function. The signature is (ctx) => Promise<void> or (ctx) => Promise<Ack>. The context object exposes the parsed message, the source vfile (with diagnostics), the resolved profile if one is attached, and a small set of helpers — ctx.respond, ctx.forward, ctx.log. Anything else a handler needs comes from the closure or from middleware that has populated ctx.data.
The handler surface is small because the other four layers do the rest. A handler knows about the business outcome — "an admit happened, persist it, emit a domain event" — and nothing about framing, parsing, dispatch, or ack construction.
Errors and acknowledgments
The runtime treats two error categories differently.
A framing or parse error never reaches a handler. The runtime knows what failed; the response is a structured AR with diagnostics.
A handler error is a thrown exception. The runtime catches it, hands the diagnostic to ackMiddleware() (or whatever ack-construction middleware the route uses), and the response is an AE.
A handler that wants to respond with AR — because it considers the partner to have sent something semantically invalid — does so explicitly with ctx.respond(ar(...)) rather than throwing. The two paths carry different information: an exception means "I cannot recover from this message"; an AR response means "I am rejecting this message for these reasons". Collapsing them into one path loses the distinction.
Trade-offs
The layered model is more pieces than a single-function MLLP receiver. A reader who wants mllp.listen(2575, handler) and nothing else has to understand that the listener, framer, parser, dispatcher, and middleware exist before configuring anything beyond the default.
Most other MLLP libraries collapse the layers behind a single object. That works for the simple case and stops working when a deployment needs to change the framer's recovery policy without rewriting the parser, share middleware across two routes that use different parsers, or swap the listener for a different transport in a test. The layered model exists for those crossings; a hospital integration bus reaches them eventually.
Further reading
- MLLP — the transport itself, including frame anatomy.
- Message flow — the standard's view of request and acknowledgment.
- Acknowledgments — what
AA,AE, andARmean.