This guide walks through moving to hl7v2 from another HL7 v2.x library. Each section pairs the library you already know with the equivalent hl7v2 API so the diff is obvious.

Covered:

At the end you'll find a feature comparison and a short adoption checklist.

From elixir_hl7

elixir_hl7 parses HL7 v2 messages as generic lists and exposes a path-based query API. hl7v2 parses into typed segment structs with named fields backed by the HL7 v2.5.1 + v2.6/v2.7 catalogs, and additionally preserves unknown segments losslessly.

1. Install

# Old
def deps, do: [{:elixir_hl7, "~> 0.9"}]

# New
def deps, do: [{:hl7v2, "~> 3.8"}]

2. Parse

# elixir_hl7
hl7 = HL7.Message.new(wire)
# => %HL7.Message{segments: [...], ...}

# hl7v2 — raw mode, same shape
{:ok, raw} = HL7v2.parse(wire)
# => %HL7v2.RawMessage{segments: [{"MSH", [...]}, {"PID", [...]}]}

# hl7v2 — typed mode, gives you structs
{:ok, msg} = HL7v2.parse(wire, mode: :typed)
# => %HL7v2.TypedMessage{segments: [%HL7v2.Segment.MSH{...}, %HL7v2.Segment.PID{...}]}

hl7v2.parse/2 returns {:ok, msg} / {:error, reason} rather than raising. Typed mode is strictly more powerful — you still get raw access to any fields by path.

3. Field access by path

elixir_hl7 uses a path string query API. hl7v2 provides the same string API plus typed struct access.

# elixir_hl7
HL7.Message.get_value(hl7, "PID-5.2")
HL7.Message.get_values(hl7, "OBX-5")

# hl7v2 — path API (mirrors elixir_hl7)
HL7v2.get(msg, "PID-5.2")
HL7v2.get_all(msg, "OBX-5")

# hl7v2 — typed access (new, preferred)
pid = Enum.find(msg.segments, &is_struct(&1, HL7v2.Segment.PID))
first_name = hd(pid.patient_name).given_name

Typed access is autocompletion-friendly, catches typos at compile time, and returns nil (not empty string) when a field is absent — so nil guards work instead of truthy-string checks.

4. Build messages

# elixir_hl7 — hand-assemble lists
segments = [
  ["MSH", "|", "^~\\&", "HIS", "HOSP", ...],
  ["PID", "1", "", "12345^^^MRN", "", "Smith^John"]
]
HL7.Message.new(segments)

# hl7v2 — struct builder
alias HL7v2.Segment.{PID, EVN, PV1}
alias HL7v2.Type.{CX, XPN, FN, PL}

msg =
  HL7v2.new("ADT", "A01",
    sending_application: "HIS",
    sending_facility: "HOSP",
    receiving_application: "PACS",
    receiving_facility: "IMG"
  )
  |> HL7v2.Message.add_segment(%EVN{event_type_code: "A01"})
  |> HL7v2.Message.add_segment(%PID{
    patient_identifier_list: [%CX{id: "12345", identifier_type_code: "MRN"}],
    patient_name: [%XPN{family_name: %FN{surname: "Smith"}, given_name: "John"}]
  })

wire = HL7v2.encode(msg)

You get compile-time safety, no manual separator management, and type-aware composite encoding (XPN, CX, PL, ...).

5. Validate

elixir_hl7 has no validation. hl7v2 ships structural, positional, conditional, field-level, version-aware, and table validation plus user-defined conformance profiles:

# Basic validation
case HL7v2.validate(msg) do
  :ok -> :ok
  {:error, errors} -> Enum.each(errors, &IO.inspect/1)
end

# Conformance profile
profile =
  HL7v2.Profile.new("Hospital_ADT", message_type: {"ADT", "A01"})
  |> HL7v2.Profile.require_segment("NK1")
  |> HL7v2.Profile.require_field("PID", 18)

HL7v2.validate(msg, profile: profile)

See guides/conformance-profiles.md for the full profile DSL.

6. ACK / NAK

# elixir_hl7 — no built-in helper, you hand-build the MSA

# hl7v2
{ack_msh, msa} = HL7v2.Ack.accept(hd(msg.segments))
accept_wire = HL7v2.Ack.encode({ack_msh, msa})

{ack_msh, msa, err} =
  HL7v2.Ack.reject(hd(msg.segments),
    error_code: "207",
    error_text: "Application internal error"
  )
reject_wire = HL7v2.Ack.encode({ack_msh, msa, err})

7. MLLP transport

elixir_hl7 does not ship an MLLP transport. hl7v2 includes an integrated Ranch 2.x listener/client with TLS and mutual-TLS support. This is typically the point where downstream users reach for a separate mllp package — with hl7v2, it's already in the box.

# Server
{:ok, _pid} = HL7v2.MLLP.Listener.start_link(port: 2575, handler: MyHandler)

# Client
{:ok, client} = HL7v2.MLLP.Client.start_link(host: "localhost", port: 2575)
{:ok, ack} = HL7v2.MLLP.Client.send_message(client, wire)

See getting-started.md for the full transport API.

8. Unknown / Z-segments

Both libraries preserve unknown segments on parse. In hl7v2:

  • Segments with an ID starting with Z become %HL7v2.Segment.ZXX{}
  • Any other unknown segment stays as {name, raw_fields}
  • Both forms encode back to wire format via HL7v2.encode/1
  • HL7v2.get/2 with a path string works uniformly across typed structs, ZXX, and raw tuples

9. Pitfalls worth knowing

If you're coming from a busy elixir_hl7 codebase, watch for these:

  • Empty-string vs nil. elixir_hl7 returns "" for absent fields; hl7v2 returns nil. Replace x != "" checks with is_nil/1 or pattern matching.
  • Repeating fields. elixir_hl7 returns a list always; hl7v2 returns a typed list for unbounded fields and a single struct otherwise. Follow the segment module's declared cardinality.
  • Separator handling. elixir_hl7 exposes separators as strings; hl7v2 threads them via a %HL7v2.Separator{} struct and handles subcomponent escaping automatically on encode.
  • Version. hl7v2 reads MSH-12 on parse and applies version-aware field presence rules (v2.3 → v2.8+). If you parse v2.3 payloads, you'll now get v2.3-correct validation instead of v2.5.1-flavored warnings.

From HAPI v2 (Java)

HAPI's model-based API maps almost 1:1 onto hl7v2:

HAPIhl7v2
PipeParser().parse(wire)HL7v2.parse(wire, mode: :typed)
message.getMSH()hd(msg.segments) (or Enum.find/2)
pid.getPatientName(0).getGivenName()hd(pid.patient_name).given_name
new ADT_A01()HL7v2.new("ADT", "A01", opts)
message.encode()HL7v2.encode(msg)
Acknowledgment.generateACK(...)HL7v2.Ack.accept(msh)
HapiContext.getValidationContext()HL7v2.Profile + HL7v2.validate/2
MinLowerLayerProtocolHL7v2.MLLP.Listener / .Client

The mental model is the same — segment objects with named accessors — but you replace generated Java classes with first-class Elixir structs and idiomatic pipelines. Segment names, field names, and composite types match the HL7 v2.5.1 spec and use snake_case instead of camelCase.

From HL7apy (Python)

HL7apy exposes HL7 as a hierarchy of attribute-addressable objects. The Elixir equivalents:

HL7apyhl7v2
parse_message(wire)HL7v2.parse(wire, mode: :typed)
msg.MSH.msh_9hd(msg.segments).message_type
msg.PID.pid_5.xpn_1.fn_1hd(pid.patient_name).family_name.surname
Message("ADT_A01")HL7v2.new("ADT", "A01", opts)
msg.to_er7()HL7v2.encode(msg)
msg.validate() (message profile)HL7v2.validate(msg, profile: profile)

HL7apy's implicit-null attribute traversal becomes explicit nil handling in Elixir — use get_in/2, pattern matching, or HL7v2.get/2 with a path string when you don't want to walk structs manually.

Feature comparison

Featurehl7v2elixir_hl7HAPI JavaHL7apy
Typed segment structsyesnoyesyes
Positional structural validationyesnoyesyes
Conditional field validationyesnoyesno
Version-aware rules (v2.3-v2.8)yesnoyesyes
Table validation (opt-in)yesnoyesyes
Conformance profiles (DSL)yesnoyespartial
ACK / NAK helpersyesnoyesyes
MLLP transport (TLS + mTLS)yesnoyesno
Unknown segment preservationyesyesyesyes
Telemetryyesnonono
Pure-BEAM, no NIFsyesyesnono

Adoption checklist

Port your codebase incrementally:

  1. Swap the dep (elixir_hl7hl7v2). Raw mode parses first.
  2. Adapt path lookupsHL7.Message.get_value/2HL7v2.get/2.
  3. Flip empty-string checks to nil checks.
  4. Move one message flow at a time to typed mode (mode: :typed), starting with the ones you know the segments for.
  5. Replace hand-assembled segment lists with struct builders.
  6. Turn on validation (validate: true) in a logging-only mode.
  7. Promote warnings to errors per integration, optionally with HL7v2.Profile.
  8. Swap your MLLP transport (mllp, handmade TCP handler) for HL7v2.MLLP.Listener + .Client.
  9. Subscribe to telemetry events for observability ([:hl7v2, :parse, :start | :stop | :exception], etc.).

If you hit a segment that isn't typed yet, it keeps parsing as a raw tuple — nothing gets lost — and you can still address it via HL7v2.get/2 paths. File an issue and it'll usually show up in the next release.