Brio-IO Communication Server
Back to the blog
by the Brio-IO team

Transformers in Brio-IO: three engines, one pipeline — and finally readable HL7 paths

How Brio-IO scripts transformers: Groovy, GraalJS and Rhino side by side, why the parsed `msg` gives readable property access (msg.PID.PID_5.PID_5_1), and how to migrate existing scripts step by step.

#transformer #scripting #hl7 #mirth #groovy

Transformers in Brio-IO: three engines, one pipeline

An integration server spends most of its time rewriting messages: normalising a field, mapping a code, discarding a message that doesn’t belong in the target channel. In Brio-IO this happens in the transformer — the step where you use a small script to shape the message into the form your target system expects.

This article shows what that looks like in practice: which script engines Brio-IO ships with, why HL7 paths are readable in Brio-IO (and not in Mirth Connect), and how to bring existing scripts over from Mirth without a big bang.

Updated July 2026 — component paths are now positional: This article has a small history, and we tell it honestly. An interim version showed components under their HL7 datatype (PID_5XPN_1FN_1), because that is what the serializer emitted back then. With the serializer modernisation (ADR-022), Brio-IO now names components by position number: PID_5_1, PID_5_2 — exactly the form this article originally showed, and the one HL7 and Mirth use. The important part: those paths now work against real messages (they previously returned a silent empty string). Every example below has been executed against the real serializer and all three engines.

Where the transformer sits in the pipeline

Every channel processes messages in the same order:

Source  →  Filter  →  Transformer  →  Destinations
  • The filter decides per message: process or discard. It returns a boolean — true means “let it through”.
  • The transformer changes the message. It receives the current message, rewrites it, and returns the new version.

Both are scripts. And this is exactly where it gets interesting, because Brio-IO doesn’t run scripts in a single language.

Three engines, one context

Brio-IO runs three script engines in parallel (see ADR-005). Which one a message passes through you decide per script — not per channel.

EngineMarkerLanguageRole
GroovygroovyApache Groovydefault for new scripts
GraalJSjs-modernmodern JavaScript (ES2024)for those who prefer to write JS
Rhinojs-legacyJavaScript with E4Xonly for imported Mirth scripts

The clever part: no matter which engine — the built-in objects are the same everywhere. A script always has access to:

  • msg — the current message; in Groovy and GraalJS a parsed XML object with property access (a string for non-XML payloads such as JSON)
  • xml — the XML helper (get/set/xpath) as a transition API for existing scripts and non-XML payloads
  • channelMap, sourceMap, responseMap — maps for the message context
  • globalMap, globalChannelMap — server-wide and channel-wide persistent maps
  • tmp — a scratchpad that only exists for this one execution
  • logger — SLF4J logger for log output
  • xslt — helper for stylesheet transformations

Scripts are compiled when a channel is deployed (compile cache), not on every message. So the editing comfort of a scripting language costs you no performance at runtime.

The dot problem every Mirth developer knows

HL7v2 messages are converted in the server into a canonical XML representation. In the classic Mirth world, every level carries a dot in the element name:

<PID>
  <PID.5>
    <PID.5.1>Schmidt</PID.5.1>
  </PID.5>
</PID>

And here the trouble begins. The dot is the property-access operator in every programming language. msg.PID.PID.5.PID.5.1 can’t be parsed — the parser reads the dots as nesting. Mirth’s way out is bracket notation with strings:

// Mirth Connect (Rhino/E4X): strings, brackets, toString()
var nachname = msg['PID']['PID.5']['PID.5.1'].toString();

That works, but it’s error-prone (forgotten quotes, wrong brackets), IDE-unfriendly (no autocomplete on strings) and simply ugly. It’s the first thing you learn in any Mirth training.

The Brio-IO answer: underscores instead of dots

Brio-IO is a new server and corrects this historical design flaw. In the canonical representation, Brio-IO separates field levels with underscores (see ADR-016) — PID.5 becomes PID_5. An underscore is not an operator in any language, so element names are suddenly perfectly normal identifiers.

A field, its components — numbered by position

Before the examples, one detail that would otherwise trip you up on your first own script. This is what a real PID segment looks like in Brio-IO:

<PID>
  <PID_3>
    <PID_3_1>12345</PID_3_1>
    <PID_3_4>MRN</PID_3_4>
  </PID_3>
  <PID_5>
    <PID_5_1>Schmidt</PID_5_1>
    <PID_5_2>Hans</PID_5_2>
  </PID_5>
</PID>

The last name lives under PID_5_1 — field PID-5, component 1. Components are numbered by position, exactly as in HL7 and in Mirth: PID-5.1 is the last name, PID-5.2 the first name, PID-3.1 the ID, PID-3.4 the assigning authority. The element name tells you the position, not a datatype you’d have to look up first.

The rule behind it is short:

  • Simple field → read it directly: msg.MSH.MSH_10 gives "MSG00001".
  • Composite field → one level per component: msg.PID.PID_5.PID_5_1 gives "Schmidt". If a component itself carries a sub-component, it goes one level deeper (PID_5_1_1).

That is exactly the numbering HL7 describes and a Mirth practitioner has in mind — the same position, only with an underscore instead of a dot (more on that in a moment). Worth knowing: accessing a path that doesn’t exist does not throw — it returns an empty string. The web UI’s message browser shows you the canonical XML of every received message if you want to check a path against a real one.

Because the element names are valid identifiers, Brio-IO binds msg in Groovy and GraalJS as an already-parsed XML object (ADR-019). So you navigate directly by property — exactly the comfort that failed at the dots in Mirth. msg is the message’s root element, so you enter below the root (no ADT_A01 prefix):

// Groovy — msg is the root node, .text() yields the field value
def nachname = msg.PID.PID_5.PID_5_1.text()   // "Schmidt"
def vorname  = msg.PID.PID_5.PID_5_2.text()        // "Hans"
// GraalJS (js-modern) — a text leaf reads directly as a string
let nachname = msg.PID.PID_5.PID_5_1;   // "Schmidt"
let patId    = msg.PID.PID_3.PID_3_1;         // "12345"

A missing field doesn’t throw — it reads tolerantly as an empty string. Property access isn’t only more readable, it’s also the faster path: it works directly on the parsed object, without re-serialising the message on every field access.

xml.get/xml.set: the transition API

For existing scripts and non-XML payloads (JSON, raw — where msg stays a string), the path helper xml remains. It reads and writes fields via a path — identical across engines, and accepts msg as both a string and a parsed object:

// Groovy — transition API: path helper (path from the root, incl. ADT_A01)
def nachname = xml.get(msg, "ADT_A01/PID/PID_5/PID_5_1")   // "Schmidt"

Unlike property access, the helper path starts at the root element (ADT_A01). As a separator it accepts both the slash and the dot. Besides get there’s set for writing and xpath for full XPath access:

// GraalJS — full XPath when a path isn't enough
let nachname = xml.xpath(msg, "//*[local-name()='PID_5_1']/text()");

A transformer changes — by returning

A transformer mutates the parsed msg object and returns it — the engine automatically serialises it back to canonical XML at the step boundary. So you don’t have to serialise anything by hand.

Example 1 — normalise the last name to uppercase:

// Groovy: read the property, set it on the node, return the changed msg
def nachname = msg.PID.PID_5.PID_5_1.text()
msg.PID.PID_5.PID_5_1[0].value = nachname.toUpperCase()
return msg
// GraalJS: set the property directly, return msg as the last expression
msg.PID.PID_5.PID_5_1 = msg.PID.PID_5.PID_5_1.toUpperCase();
msg;

In Groovy you need an explicit return and set the value on the node via [0].value. In GraalJS the last expression is enough — that’s why msg simply stands there as the last line, and the field is set by direct assignment.

Example 2 — carry a value into a map context and set a field:

// Groovy: remember the patient identifier and populate a target field
def patientId = msg.PID.PID_3.PID_3_1.text()
channelMap.put("patientId", patientId)
msg.PID.PID_3.PID_3_1[0].value = patientId.trim()
return msg

channelMap survives within the message all the way to the destinations — handy for passing values between filter, transformer and target templates.

Filter: return a boolean

A filter only decides yes/no. It must return a boolean; true lets the message through, anything else discards it.

// Groovy: only process messages with a patient ID set
return msg.PID.PID_3.PID_3_1.text() != ""
// GraalJS: the last expression is the result
msg.PID.PID_3.PID_3_1 !== "";

The same message, three idioms — side by side

Reading a field looks like this in all three engines — the same field three times, against the same msg:

// Brio-IO Rhino (js-legacy) — E4X brackets, as in Mirth
msg['PID']['PID.5']['PID.5.1'].toString()
// Brio-IO Groovy (default) — parsed msg, property access
msg.PID.PID_5.PID_5_1.text()
// Brio-IO GraalJS (js-modern) — parsed msg, text leaf as a string
msg.PID.PID_5.PID_5_1

That Rhino sees the dot variant of the same message (PID.5.1 instead of PID_5_1) is handled by the pipeline processor — you don’t have to worry about it.

What this means for an existing Mirth script, honestly: the mechanics come over unchanged — E4X brackets, mutation, returning msg at the end, and the new XML(msg) you used to need is gone. And the component paths too: Mirth’s msg['PID']['PID.5']['PID.5.1'] runs in Brio-IO js-legacy unchanged — both number by position. Segment, field and component level stay exactly as you know them. The only difference is underscore instead of dot in Groovy/GraalJS, and the pipeline processor sets that automatically per engine — you don’t have to worry about it.

When you navigate complex structures in Groovy or iterate over many repeating fields, full XPath access via xml.xpath is open to you as well.

Rhino (js-legacy): Mirth compatibility with an expiry date

So why keep Rhino at all? Because Mirth legacy scripts are written in JavaScript with E4X and should keep running as unchanged as possible. On Mirth import, Brio-IO automatically sets imported scripts to js-legacy.

So that the same scripts keep running, Rhino is the only engine that gets the dot notation in the XML — the world Mirth scripts expect. In Rhino msg is directly an E4X XML object — a new XML(msg) is no longer needed, you access it just as you did in Mirth:

// Rhino (js-legacy): msg is already E4X — bracket access as in Mirth
var nachname = msg['PID']['PID.5']['PID.5.1'].toString();

Mutation and return stay Mirth-familiar too — the engine serialises the E4X object back to XML at its boundary:

// Rhino: set a field, return msg as the last expression
msg['PID']['PID.5']['PID.5.1'] = 'Mueller';
msg;

Rhino is deliberately deprecated. On every deploy of a channel with at least one Rhino script, Brio-IO emits a deprecation warning — in the log and in the API response — and lists the affected scripts by name. A warning also appears in the web UI’s script editor as soon as you select js-legacy. The sunset path is fixed (ADR-016):

VersionBehaviour
Brio-IO 1.x (today)Rhino runs, warning on channel deploy
Brio-IO 2.x (~2027)warning becomes more prominent (banner in channel editor)
Brio-IO 3.x (~2028)Rhino can only be enabled via a feature flag
Brio-IO 4.0 (~2029)Rhino and dot notation are removed

So you have three years’ runway to migrate scripts calmly — script by script, not as a big bang.

In practice: creating a transformer in the UI

In the web UI you create a transformer directly in the channel editor. The editor is based on Monaco (the same engine as in VS Code), with syntax highlighting per language. You choose the engine per script — a channel can easily combine a Groovy filter and a js-legacy transformer from Mirth. The pipeline processor automatically serialises the message into the right notation before each step (underscore for Groovy/GraalJS, dot for Rhino) — you don’t have to worry about it.

A typical migration path from Mirth looks like this:

  1. Put the scripts on js-legacy — E4X mechanics and component paths keep running unchanged (positional, just like Mirth). A glance at the message browser confirms the canonical XML of every received message.
  2. Switch script by script to Groovy (recommended) or GraalJS, replacing the bracket strings with property access (msg.PID.PID_5.PID_5_1 / in Groovy with .text()).
  3. Done once no js-legacy script remains — then the deprecation warning disappears too.

Outlook

A detailed migration guide from Rhino to Groovy is in the works — with pattern-by-pattern translations of the most common E4X idioms, including creating segments (createSegment) and handling escape sequences. We’ll link it here as soon as it’s ready.

Until then you can see the engines live: the public demo runs channels with real transformers, and the message browser shows you the canonical XML your paths would run against.

And if you’re thinking about lifting your Mirth channels to Brio-IO and want to see how your real scripts translate — ask us for an Early Access account. Just tell us which interfaces you want to integrate.