Every integration you add speaks its own dialect. Let those dialects leak into your core and the codebase quietly rots. Here is the pattern that stops it — and how it plays out mapping X12 837 and 835 to FHIR.
If you build healthcare software for any length of time, you accumulate formats. Claims arrive as X12 5010 — an 837 for the claim, an 835 for the remittance. Payers are now standing up FHIR APIs for the same data under CMS-0057 and the CARIN Blue Button guides. An EHR feed shows up as HL7 v2. Each is a different way of saying almost the same thing, and each is fiddly in its own way.
The tempting move is to teach your application all of them: a little X12 loop-walking here, a FHIR profile quirk there, a if (payer === "...") special-case somewhere else. It works for the first two formats. By the fifth, your business logic is a swamp of segment indices and resource-shape trivia, and no one can change anything without breaking a payer. There is a well-worn fix for exactly this, and it is worth applying deliberately: an anti-corruption layer.
The rule: dialects stop at the boundary
An anti-corruption layer is a simple discipline with a big payoff. Every external format is parsed by a dedicated adapter into one canonical internal model — a neutral shape that belongs to you, not to X12 or FHIR or any vendor. Everything downstream — your workflow engine, your rules, your storage, your APIs — only ever sees that canonical model. The messy, dialect-specific code lives at the edge and nowhere else.
The consequences are the whole point:
- Your core stays clean. No X12 loop numbers, no FHIR profile URLs, no payer conditionals leaking into business logic.
- Adding a format is adding an adapter. When an HL7 v2 feed shows up next quarter, you write one adapter that produces the same canonical model — and nothing downstream changes.
- You can test the hard part in isolation. Parsing an 837 correctly is a self-contained problem with a clear contract: bytes in, canonical claim out.
We have built this exact boundary at scale — a translation layer that mapped a dozen external message schemas into one canonical model so the systems behind it never had to know which partner a message came from. The healthcare version is the same shape, and below is a concrete, runnable version of it for claims.
Designing the canonical model
The canonical model is the most important design decision, and the rule for it is: it should read like neither X12 nor FHIR. It is your domain's vocabulary. For a professional claim, that is roughly a claim id, the patient and subscriber, the billing provider (with NPI and tax id), a list of diagnoses (ICD-10-CM, principal vs. secondary), and a list of service lines (each with a CPT/HCPCS code, modifiers, a charge, units, and pointers to the diagnoses it supports). For a remittance, it is a payment, and per claim the billed and paid amounts, the patient responsibility, and the adjustments — each carrying a group code and a CARC — down to the service-line level, with RARC remark codes attached.
Notice what is not there: no CLM segment, no HL loop, no Claim.item.productOrService. Those are dialect details. The canonical model is where your engineers actually reason about a claim.
X12 837 → canonical → FHIR Claim
The 837 adapter does the unglamorous work so nothing else has to. X12 is a positional, segment-oriented format, and the first real trap is that you cannot hardcode the delimiters — the element separator, sub-element separator, and segment terminator are declared in the fixed-width ISA segment itself and vary by sender. So step one is always: read the ISA, learn the delimiters, then tokenize into segments and elements.
From there you walk the hierarchical (HL) loops and pull what the canonical model needs:
- 2010AA billing provider — name, NPI (
NM1*85), tax id (REF*EI) →Claim.provider - 2000B / 2010BA subscriber & patient — name (
NM1*IL), demographics (DMG) →Claim.patient+ insurance - 2300 claim —
CLM(id, total charge, place of service),HIdiagnoses (qualifierABK= principal,ABF= secondary) → theClaimand itsClaim.diagnosis[] - 2400 service lines —
SV1(a composite of CPT/HCPCS + modifiers, charge, units, diagnosis pointers) →Claim.item[]
Only after the canonical claim exists does a separate emitter turn it into a FHIR R4 Claim — diagnoses become diagnosisCodeableConcept with ICD-10-CM coding, service lines become item[] with a productOrService CPT code, modifier[], a net amount, and diagnosisSequence pointers. Because the emitter reads canonical and not X12, the FHIR-shaping logic never has to know an 837 existed.
X12 835 → canonical → FHIR ClaimResponse
The remittance is where this pays off most, because an 835 is dense with the information every RCM team actually chases: why did the payer pay what they paid? The adapter parses the payment (BPR, TRN), then per claim the CLP (billed, paid, patient responsibility, status), the CAS adjustments (a group code plus a CARC and amount), the service-line detail (SVC), and the LQ remark codes (RARC).
Mapped to a FHIR ClaimResponse, those adjustments stop being opaque EDI and become structured, queryable data: each CARC lands in item.adjudication as a coded reason with its amount, each RARC becomes a processNote, the payment becomes ClaimResponse.payment, and the claim status becomes outcome. That is the difference between "we got an 835 file" and "we can report, by CARC, exactly where our money is going" — the foundation of any real denial-management workflow.
Why this is the right shape for CMS-0057
This is not a purist exercise. Under the CMS Interoperability and Prior Authorization Final Rule, payers must expose claims and related data through FHIR APIs (aligned to the CARIN Blue Button and Da Vinci guides) — while X12 remains the mandated transaction rail for the actual claim and remittance. In other words, the industry is going to be running both X12 and FHIR for the same data for years. The organizations that handle that gracefully will be the ones with a clean boundary between the two, not the ones with X12 and FHIR tangled through their business logic. An anti-corruption layer is precisely that boundary.
See it in code — it's open source
We built a small, runnable reference implementation of this pattern and open-sourced it: a TypeScript mapping engine with X12 837/835 parsers, a neutral canonical model, FHIR Claim/ClaimResponse emitters, tests against real-shaped sample messages, and a live playground where you paste an X12 message and watch it become canonical and then FHIR. Read the code, run the demo, or build on it: github.com/Nirmitee-tech/x12-fhir-mapper. (It's a focused reference, not a full X12 validator — the README is honest about the edges.)
If you want the workflow that consumes this canonical data next, we wrote up (and open-sourced) a prior-authorization workflow engine in the same spirit.
Where we come in
We build FHIR-native, standards-first healthcare platforms — and, underneath them, the unglamorous integration layers that make X12, HL7, and FHIR coexist without turning a codebase into a swamp. If you're staring at a pile of payer dialects and a 2027 deadline, let's talk.
