10 September 2026

Sixth release: Serde support for HL7 v2 and v3

Three new crates — serde-hl7, serde-hl7-v2, and serde-hl7-v3 — put a parsed HL7 v2 message, its dictionary-named tree, its validation findings, and a decoded HL7 v3 interaction through any Serde format. Two dependencies each, hand-written impls, and the fourteen existing crates unchanged.

What was released

The workspace's seventh release adds three crates, each at 0.1.0, and changes nothing already published: hl7-2 0.3.0 and hl7-3 0.2.0 are the versions the new crates depend on, and no other crate's manifest was touched.

  • serde-hl7-v2 — Serde for hl7-2: the message, its dictionary-named tree, its validation findings, and the release it was read as.
  • serde-hl7-v3 — Serde for hl7-3: the three-level envelope, the six RIM backbone classes, the data types, and the raw XML element tree beneath them.
  • serde-hl7 — the umbrella: serde_hl7::v2 and serde_hl7::v3, each behind a feature of the same name, both on by default. The shape of the hl7 crate, one layer up.
sh
cargo add serde-hl7          # serde_hl7::v2 and serde_hl7::v3, both on by default

The flagship path

rust
use serde_hl7::v2::Message;

let message = Message::parse(text)?;
let json = serde_json::to_string(&message)?;      // {"version":"2.5","er7":"MSH|^~\\&|..."}
let back: Message = serde_json::from_str(&json)?;
assert_eq!(back.to_er7(), text);

The full walkthrough is Serde: JSON, YAML, and any format; the dated entry with the versions is in CHANGELOG.md.

Why three crates, and not a feature

The question that started this, asked on the day of the release, was whether code should be extracted from the workspace into a crate called serde-hl7, to make it clearer which code does serialization. The evaluation found nothing to extract: no crate here used Serde, and that was a stated property rather than an accident — Patient data lists “no serialization framework” among the things a security review can grep for. So the brief changed: build a real serde-hl7, the way serde-er7 had been built over er7, and publish it. The reasoning is recorded in serde-hl7/plan.md.

A feature flag on hl7-2 would have put serde in the dependency tree of every caller who enabled it, and in the manifest every caller has to read. A separate crate keeps the fourteen existing crates' claim exactly as it was: serde reaches only the callers who add the bridge, and each bridge depends on exactly two things — serde and the crate it wraps.

The shapes, in one paragraph each

A v2 message is its ER7 text plus its release. Two keys, version and er7. Deserializing parses the text again with the version pinned, so the dictionary is resolved on the way in and never shipped. The segment/field tree is deliberately not repeated here: the encoding layer already has serde-er7, and two crates specifying one wire shape for one set of bytes is one too many. What only this crate can add — because only hl7-2 has the dictionary — is the tree with PID.5 and XPN.1 for names: one object per node, six keys, Serialize only, because hl7-2 builds a tree only from a parsed message. Findings serialize as four keys and round-trip. A message parsed through a vendor's own dictionary comes back through Message::seed(&options), a DeserializeSeed carrying what a stateless Deserialize cannot.

A v3 value is an object whose keys are HL7® v3's own names. classCode, codeSystem, interactionId: the lowerCamelCase field name, which is the XML attribute or element name wherever a field has one, so JSON and XML cross-reference without a table. Every key is serialized every time; on the way back in, only the keys that name a thing are required. The fourteen object types are written by one macro_rules! so their shapes cannot drift — a local macro, not a derive, so still no proc-macro dependency. What is promised is a value round trip: every field is public plain data, so a value through any format and back compares equal. What is not promised is the original XML, because hl7-3 has no XML writer.

Strict mode, for fixtures

Both crates ignore an unknown key by default, which is right for reading what a sender sent and wrong for exactly one case: a typo in a hand-written test fixture, which is then read as if the key had never been written. Strict<T> is the opt-in that turns the typo into an error — a separate type at the one call site that wants it, and in v3 nested to any depth. Each crate ships a runnable example of the typo being caught, next to its round trip.

What did not change

Two other things held. The workspace's release runbook says the first release of a brand-new crate stays the maintainer's call, and this one was directed by the maintainer explicitly. And the root plan's “no new crates” non-goal was overridden for these three and reworded rather than deleted, so the override is on the record.

What is deferred

  • Deserialize for the v2 tree. It needs a public constructor on hl7_2::Node, which is hl7-2's decision to make, not this crate's.
  • Serde for struct mode. A FromHl7 or FromElement struct is yours; deriving Serde for it is one line in your own crate and needs nothing from these.
  • Dictionaries. They are JSON already, in the shape Dictionary::from_json reads. A second shape would be a second thing to keep in step.

Each is stated, with its reasoning, in the roadmap section of the crate's own spec/index.md. If a wire shape on this page does not match what the crate emits, the spec is right, and the difference is a bug worth filing.