Tutorial
Taming a vendor dialect
A vendor sends a message with segments the standard has never heard of. This is the common case, not the exception — and the way through it is three stages, in order, each of which leaves you able to ship.
The situation
A new sender is going live in three weeks. They have sent one sample file. It is an ADT^A01 with a ZAC segment nobody at either organization can fully
explain, and their integration engineer is on leave.
The instinct is to ask for a specification and wait. The better move is to look at what you have — because a message that will not be rejected can be explored, and these crates are built so that it never is.
Stage 1 — look at it
Start with the tool, not with code. No project, no dependencies, no build.
$ hl7-v2 --paths samples/vendor.hl7
ADT_A01
MSH [MSH[1]]
...
ZAC [ZAC[1]]
ZAC.1 = 7 [ZAC[1]-1[1]]
ZAC.2 [ZAC[1]-2[1]]
ZAC.2.1 = SMITH [ZAC[1]-2[1].1]
ZAC.2.2 = JOHN [ZAC[1]-2[1].2]
ZAC.3 = 20260814 [ZAC[1]-3[1]]Two things already happened without you doing anything:
- Everything standard is already named.
PID.5broke intoXPN.1andXPN.2because the dictionary knows whatPID-5holds. - The vendor's own
ZACis there, positionally, nothing lost. Unknown does not mean dropped.
The bracketed paths are not decoration: each one is exactly what reads that value back. Pull one out and see what you get across the whole sample file:
$ hl7-v2 --query 'ZAC-2.1' samples/vendor.hl7
SMITH
JONESZAC.2 is clearly a person's name — family in component 1, given in component 2.
That is a hypothesis you can now test against every message in the sample, which is more than
the missing specification would have told you.
Check what the dictionary thinks, too:
$ hl7-v2 --check samples/vendor.hl7
warning: ZAC[1]: segment is not defined in this dictionary
okStage 2 — write down what you learned
You concluded that ZAC-2 is an XPN. Say so — in JSON, without touching
any code.
acme.json
{
"inherits": "2.5",
"segments": { "ZAC": ["SI", "XPN", "DT"] }
}$ hl7-v2 --dictionary samples/acme.json --flat samples/vendor.hl7 | grep -A 2 'ZAC.2$'
ZAC.2
XPN.1 = SMITH
XPN.2 = JOHNZAC.2 now reads as an XPN like any standard name field. And when the
vendor adds ZAC-4 next quarter, it is one line in a file rather than a release of
your software.
Load the same file from Rust:
use std::sync::Arc;
let text = std::fs::read_to_string("acme.json")?;
let dictionary = hl7_2::Dictionary::from_json(&text, "acme")?;
let options = hl7_2::Options::new().with_dictionary(Arc::new(dictionary));
let message = hl7_2::parse_with_options(&message_text, &options)?;Stage 3 — freeze what is stable
Once the interface has held still long enough to trust, move it into the type system and let the compiler carry it.
use hl7_2::{FromHl7, Raw};
#[derive(FromHl7)]
struct AcmeAdmission {
#[hl7("PID-3.1")] patient_id: String,
#[hl7("ZAC-2.1")] clinician_family: String,
#[hl7("ZAC-3")] effective: Option<String>,
#[hl7(raw)] raw: Raw,
}
let admission: AcmeAdmission = hl7_2::parse(text)?.decode()?;A typo in "ZAC-2.1" is now a build failure. The mapping lives in one place instead
of being scattered across every call site. This is the payoff for the first two stages.
If the site has XSDs instead
Some sites do not have a specification but do have XML Schemas — HL7's published v2.xml schemas, or a vendor's customised copy. Skip stage 2's hand-writing and generate the dictionary:
hl7-2-from-xsd-into-json-dictionary schemas/acme \
--name acme \
--alias ADT_A28=ADT_A05 \
--inherits 2.5 \
-o acme.jsonA generated dictionary carries cardinality as well as data types, because the
schemas know each field's minOccurs and maxOccurs. That is what lets
the XML converter run in --schema-shape mode and emit a document which validates
against the very schemas it came from. See Vendor dictionaries.
One thing the schemas cannot tell you: which trigger events arrive carried by another message's
structure. A directory holds ADT_A05.xsd but never says an ADT^A28 is
one — hence --alias.
Stage 3 is never final
Keep the #[hl7(raw)] field. It is the whole reason this workflow closes rather than
dead-ends.
// A field nobody warned you about arrived. You are back at stage 1,
// on the same object, with no re-parse and no rewrite.
if let Some(value) = admission.raw.get("ZAC-4")? {
log::warn!("unmodelled ZAC-4: {value}");
}Real feeds are stable until they are not, and the usual choice at that moment is to re-parse the
raw message or rewrite the library. A Raw field keeps the whole parsed message
beside the typed data, so the day something arrives that the struct does not model, you are back
at stage 1 on the same object — no second parse, no redeployment.
Checklist
- Look.
hl7-v2 --paths sample.hl7. Confirm nothing is dropped, and collect paths. - Probe.
hl7-v2 --queryacross every sample you have, to test each hypothesis about what a field means. - Check.
hl7-v2 --check. Warnings tell you where the dictionary needs extending. - Write. A JSON dictionary inheriting a bundled release, stating only the dialect — or generate one from XSDs.
- Apply.
Options::with_dictionary, and nothing else in your code changes. - Freeze. A struct, once the shape has held still.
- Keep the escape hatch.
#[hl7(raw)], always.