DEV Community

Geminate Solutions
Geminate Solutions

Posted on

EHR Integration: Converting HL7 v2 ADT to FHIR in Node.js

If your product needs EHR integration with hospitals, expect admit, discharge and transfer events to arrive as HL7 v2 ADT messages, not FHIR. The fix is a small Node.js service that parses each message, maps the PID and PV1 segments to FHIR Patient and Encounter resources, and sends back an ACK only after the message is safely stored.

You asked the hospital for a FHIR API. Their interface team sent you an IP address, a port and a sample file full of pipes and carets. This tutorial starts from exactly that point, with code you can adapt today.

Why do hospitals still send HL7 v2 instead of FHIR?

Their ADT feeds were built long before FHIR existed, and they still work. The registration system fires an event every time a patient is admitted (A01), transferred (A02), discharged (A03) or updated (A08). An interface engine then pushes it over MLLP, a thin framing layer on top of TCP.

Rebuilding that pipeline is not on the hospital's roadmap, so the translation falls to you.

What does an ADT message look like?

Each line is a segment, ended by a carriage return. Fields are split by |, components by ^, repetitions by ~.

MSH|^~\&|ADT_APP|CITY_HOSP|YOUR_APP|YOUR_ORG|202610171030||ADT^A01^ADT_A01|MSG00042|P|2.5.1
EVN|A01|202610171028
PID|1||884213^^^CITY_HOSP^MR~X7731^^^PAYER^MB||Shah^Riya^K||19870412|F
PV1|1|I|4W^412^B||||||||||||||||V55120|||||||||||||||||||||||||202610171025
Enter fullscreen mode Exit fullscreen mode

MSH is the envelope: who sent it, the event type in MSH-9 and a unique control ID in MSH-10. PID carries identifiers, name, birth date and sex. PV1 carries the visit: patient class, bed location, visit number in PV1-19 and admit time in PV1-44.

Never hardcode the separators. MSH-1 and MSH-2 declare them, and some senders change them.

What does getting this wrong cost you?

A discharge that never lands leaves a patient "admitted" in your app for days. Duplicate patients split one person's history across two records, and merging them later is slow manual work that needs clinical sign-off. Once a ward nurse sees wrong data twice, they stop trusting your screen and go back to the old system.

Lost messages are the worst case, because nobody notices until an audit or a complaint.

Do you need outside help with this?

Probably not if you connect to a single hospital that already runs an interface engine such as Mirth Connect or Rhapsody and is willing to transform to FHIR on its side. Ask them first. That can turn your build into a configuration task.

The work gets harder when you plan to onboard many hospitals, each with its own quirks, or when the feed drives clinical decisions in real time.

How do you parse the segments in Node.js?

Read the separators from the header, split each segment into fields, and expose two helpers: one for a single value and one for repeating fields. Shift MSH by one position, because the field separator itself counts as MSH-1.

function parseHL7(raw) {
  const text = raw.replace(/\r\n|\n/g, '\r').trim()
  if (!text.startsWith('MSH')) throw new Error('Not an HL7 message')
  const sep = { field: text[3], comp: text[4], rep: text[5], esc: text[6], sub: text[7] }
  const segs = {}

  for (const line of text.split('\r').filter(Boolean)) {
    const fields = line.split(sep.field)
    if (fields[0] === 'MSH') fields.splice(1, 0, sep.field) // MSH-1 is the separator
    if (!segs[fields[0]]) segs[fields[0]] = []
    segs[fields[0]].push(fields)
  }

  const esc = code => sep.esc + code + sep.esc
  const decode = v => v && v
    .replaceAll(esc('F'), sep.field)
    .replaceAll(esc('S'), sep.comp)
    .replaceAll(esc('R'), sep.rep)
    .replaceAll(esc('T'), sep.sub)
    .replaceAll(esc('E'), sep.esc)

  // every repetition of a field, each split into components
  const repeats = (seg, n) => (segs[seg]?.[0]?.[n] || '')
    .split(sep.rep).filter(Boolean)
    .map(r => r.split(sep.comp).map(decode))

  // one component of the first repetition
  const get = (seg, n, c = 1) => repeats(seg, n)[0]?.[c - 1] || undefined

  return { get, repeats }
}
Enter fullscreen mode Exit fullscreen mode

Libraries exist for this and are fine to use. Writing the core yourself takes an afternoon, and you will understand every field when a feed misbehaves.

How do you map PID and PV1 to FHIR resources?

Map only what you trust and leave out what is missing. A Patient with no birth date is honest. A Patient with a made-up birth date is a clinical risk.

const TZ = '+05:30' // the facility's offset, from config
const GENDER = { M: 'male', F: 'female', O: 'other', U: 'unknown' }
const CLASS = { I: 'IMP', O: 'AMB', E: 'EMER' }
const STATUS = { A01: 'in-progress', A02: 'in-progress', A03: 'finished', A04: 'in-progress', A11: 'cancelled' }

function hl7Date(v, tz = '') {
  const m = /^(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?/.exec(v || '')
  if (!m) return undefined
  const [, y, mo, d, h, mi = '00', s = '00'] = m
  if (!h) return [y, mo, d].filter(Boolean).join('-')
  return y + '-' + mo + '-' + d + 'T' + h + ':' + mi + ':' + s + tz
}

function toPatient(msg) {
  return {
    resourceType: 'Patient',
    identifier: msg.repeats('PID', 3).filter(c => c[0]).map(c => ({
      system: 'urn:id:' + (c[3] || 'unknown') + ':' + (c[4] || 'unknown'),
      value: c[0],
    })),
    name: msg.repeats('PID', 5).map(c => ({
      family: c[0] || undefined,
      given: [c[1], c[2]].filter(Boolean),
    })),
    gender: GENDER[msg.get('PID', 8)],
    birthDate: hl7Date(msg.get('PID', 7))?.slice(0, 10),
  }
}

function toEncounter(msg, patientRef) {
  const [unit, room, bed] = msg.repeats('PV1', 3)[0] || []
  return {
    resourceType: 'Encounter',
    identifier: [{ system: 'urn:id:CITY_HOSP:visit', value: msg.get('PV1', 19) }],
    status: STATUS[msg.get('MSH', 9, 2)], // undefined for A08, so status is left alone
    class: {
      system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
      code: CLASS[msg.get('PV1', 2)] ?? 'AMB', // agreed fallback, log when used
    },
    subject: { reference: patientRef },
    period: {
      start: hl7Date(msg.get('PV1', 44), TZ),
      end: hl7Date(msg.get('PV1', 45), TZ),
    },
    location: unit
      ? [{ location: { display: [unit, room, bed].filter(Boolean).join(' / ') } }]
      : undefined,
  }
}
Enter fullscreen mode Exit fullscreen mode

This targets FHIR R4, where Encounter.class is a single required Coding. Agree the fallback with the hospital and log each time it fires. A08 is left out of STATUS on purpose: an update should not change the encounter status, so you merge only the fields that are present.

Write resources with conditional updates, for example PUT /Patient?identifier=urn:id:CITY_HOSP:MR|884213. A repeated admit then updates the same patient instead of creating a twin. In production, swap the urn:id systems for the identifier URIs you agree with each hospital.

Which messy fields cause the most trouble?

These break feeds in production. Here is how to spot each one early:

  • Explicit nulls. A field containing "" means "delete this value". An empty field means "no change". Treat them the same and you either wipe good data or keep stale data.
  • Multiple identifiers. PID-3 repeats. Pick the medical record number by type code (MR) and assigning authority, never by position.
  • Timestamps without offsets. Most ADT times carry no time zone. Use the facility's zone from config, and prefer the sender's offset when one is present.
  • Partial dates. A birth date of 1987 is valid in both HL7 and FHIR. Don't pad it to January 1.
  • Local codes. Patient class, sex and location codes vary by site. Keep the lookup tables in config, per hospital.
  • Out-of-order events. After a resend, an A08 can arrive before its A01. Compare the event time in EVN-2 with what you stored and skip older updates.

Log a warning per bad field and keep going. Rejecting a whole admit over one malformed phone number does more harm than storing it without the number.

When should you send the ACK?

After the raw message is durably stored, and before any FHIR mapping. The sender reads AA (accept) as "this is yours now" and moves on to the next message.

Send the ACK after the write, never before it.

const net = require('net')
const VT = '\x0b', FS = '\x1c', CR = '\r'

function buildAck(msg, code, text = '') {
  const now = new Date().toISOString().replace(/\D/g, '').slice(0, 14)
  const msh = [
    'MSH', '^~\\&',
    msg.get('MSH', 5), msg.get('MSH', 6), // you are now the sender
    msg.get('MSH', 3), msg.get('MSH', 4),
    now, '', 'ACK^' + msg.get('MSH', 9, 2) + '^ACK', 'ACK' + now,
    msg.get('MSH', 11), msg.get('MSH', 12),
  ].join('|')
  return msh + CR + ['MSA', code, msg.get('MSH', 10), text].join('|')
}

async function handle(raw) {
  const msg = parseHL7(raw)
  const controlId = msg.get('MSH', 10)
  if (!controlId) return buildAck(msg, 'AR', 'Missing MSH-10')
  await inbox.saveOnce(controlId, raw) // durable insert, ignores duplicates
  return buildAck(msg, 'AA')
}

net.createServer(socket => {
  let buffer = ''
  socket.on('data', async chunk => {
    socket.pause() // keep messages in order while you write
    buffer += chunk.toString('utf8')
    let end
    while ((end = buffer.indexOf(FS + CR)) !== -1) {
      const raw = buffer.slice(buffer.indexOf(VT) + 1, end)
      buffer = buffer.slice(end + 2)
      try {
        socket.write(VT + (await handle(raw)) + FS + CR)
      } catch (err) {
        console.error('Not stored, no ACK sent', err) // sender will resend
      }
    }
    socket.resume()
  })
}).listen(2575)
Enter fullscreen mode Exit fullscreen mode

AE signals an application error and AR a rejection. Use AR for messages you can never process, such as one with no control ID. Messages that fail to parse at all should be saved to a quarantine table and rejected too. Otherwise the sender retries forever and the queue behind them stalls.

Deduplicate on MSH-10. Engines resend when an ACK times out, so the same message will arrive twice. A separate worker then reads the inbox, runs the mapping and moves failures to a dead-letter table that someone actually checks.

In what order should you build it?

  1. Collect sample messages for every event type you will receive, including messy real ones with identifiers removed.
  2. Build the MLLP listener, the durable inbox and the ACKs first. Nothing else matters if messages get lost.
  3. Write the parser and mapping, with a test for each sample.
  4. Add deduplication, ordering checks and the dead-letter table.
  5. Run against the hospital's test environment, then compare a full day of traffic with their census report before go-live.

If a hospital cannot share samples, ask for their interface specification and treat every optional field as missing until a real message proves otherwise.

Hosting, consent and audit trails shape a feed like this as much as the code does. The healthcare app development guide covers those choices. For a second opinion on your mapping or ACK flow, ask Geminate Solutions for a free review of your integration plan. You will get a reply within 24 hours.

Top comments (0)