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.

sh
$ 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.5 broke into XPN.1 and XPN.2 because the dictionary knows what PID-5 holds.
  • The vendor's own ZAC is 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:

sh
$ hl7-v2 --query 'ZAC-2.1' samples/vendor.hl7
SMITH
JONES

ZAC.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:

sh
$ hl7-v2 --check samples/vendor.hl7
warning: ZAC[1]: segment is not defined in this dictionary
ok

Stage 2 — write down what you learned

You concluded that ZAC-2 is an XPN. Say so — in JSON, without touching any code.

acme.json

json
{
  "inherits": "2.5",
  "segments": { "ZAC": ["SI", "XPN", "DT"] }
}
sh
$ hl7-v2 --dictionary samples/acme.json --flat samples/vendor.hl7 | grep -A 2 'ZAC.2$'
    ZAC.2
      XPN.1 = SMITH
      XPN.2 = JOHN

ZAC.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:

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.

rust
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:

sh
hl7-2-from-xsd-into-json-dictionary schemas/acme \
    --name acme \
    --alias ADT_A28=ADT_A05 \
    --inherits 2.5 \
    -o acme.json

A 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.

rust
// 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

  1. Look. hl7-v2 --paths sample.hl7. Confirm nothing is dropped, and collect paths.
  2. Probe. hl7-v2 --query across every sample you have, to test each hypothesis about what a field means.
  3. Check. hl7-v2 --check. Warnings tell you where the dictionary needs extending.
  4. Write. A JSON dictionary inheriting a bundled release, stating only the dialect — or generate one from XSDs.
  5. Apply. Options::with_dictionary, and nothing else in your code changes.
  6. Freeze. A struct, once the shape has held still.
  7. Keep the escape hatch. #[hl7(raw)], always.