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.
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_5→XPN_1→FN_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 —
truemeans “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.
| Engine | Marker | Language | Role |
|---|---|---|---|
| Groovy | groovy | Apache Groovy | default for new scripts |
| GraalJS | js-modern | modern JavaScript (ES2024) | for those who prefer to write JS |
| Rhino | js-legacy | JavaScript with E4X | only 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 payloadschannelMap,sourceMap,responseMap— maps for the message contextglobalMap,globalChannelMap— server-wide and channel-wide persistent mapstmp— a scratchpad that only exists for this one executionlogger— SLF4J logger for log outputxslt— 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_10gives"MSG00001". - Composite field → one level per component:
msg.PID.PID_5.PID_5_1gives"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.
Property access on msg: the recommended way
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):
| Version | Behaviour |
|---|---|
| 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:
- 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. - 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()). - Done once no
js-legacyscript 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.