|
17 min read
|

One FHIR Store, Two Versions: Serving R4 and R6 with FML

Article Summary

R4 and R6 will run side by side for years, and at FHIR Camp 2026 the people living with it were clear: they want one dataset, not a copy per version. So we built a server that stores FHIR R4 once and serves R6 through FML maps in both directions. Patient converted unchanged. Encounter needed renames, status translation and cross-version extensions. Search needed a second parameter. BodyStructure had to be split into several R4 resources, which broke references until we fixed them. The tools did their job. Every hard decision was about the model.

Summarize this article with:
ChatGPTPerplexityClaudeGrok
R4 clients read and write a PostgreSQL store of FHIR R4 directly. R6 clients go through an FML converter that maps R4 to R6 on reads and R6 to R4 on writes.
One R4 store, two APIs. R6 is computed from the stored R4 on every request.

Why one server for two versions

R6 is in its final ballots, and the question has changed. It is no longer "what's new in R6" but "how long will we run it next to R4". Everything we know says: for years.

  • Regulation pins versions. US certification (US Core) and the CMS interoperability rules for Patient Access, Provider Access, Payer-to-Payer and Prior Authorization are all built on R4. New rules will come with transition windows, not a cut-over date. Other jurisdictions move on their own timelines.
  • Partners don't upgrade together. If you are a platform, an HIE or a payer, you get the union of everyone's versions.
  • Long tails are normal. Epic still serves DSTU2, STU3 and R4, each at its own base URL. Oracle Health reportedly ran DSTU2 next to R4 for about six years before retiring it in 2025.
  • R5 was mostly skipped. Production systems stayed on R4 and took the R5 features they needed, such as topic-based Subscriptions, through R4 backport IGs. So the realistic jump is R4 straight to R6, with every R4 → R5 and R5 → R6 change at once.
  • Data outlives versions. Records written today will be read in ten or twenty years. Archives and audit trails don't get rewritten.

The changes are not cosmetic, either. A few that we ran into:

R4R6Why it hurts
Encounter.class: one CodingEncounter.class: a list of CodeableConceptSeveral classes don't fit in one
Encounter.status finished, onleave, arrivedcompleted, on-hold, in-progress + subjectStatusDifferent codes, not one-to-one
Encounter.period, hospitalization, participant.individualactualPeriod, admission, participant.actorRenames
BodyStructure.location and morphology: one eachBodyStructure.includedStructure: one or moreOne R6 resource can describe what R4 needs several resources for
Observation.bodySite: a codeadds Observation.bodyStructure: a referenceR4 has no element to hold the link

So coexistence is a given. The real question is how to do it inside one system.

What the room said at FHIR Camp

At HL7 FHIR Camp 2026 in Cascais, Portugal, I hosted a Birds-of-a-Feather session called Multiple FHIR versions coexistence in the same system. The question: should one system serve several versions from one dataset with converters, or keep a separate copy per version, or store each resource in whatever version it arrived in?

The room's answer was clear: people want a single dataset. The reasons are practical:

  • One source of truth. A patient exists once. With a copy per version, the copies drift apart, and someone has to decide which one is right.
  • No races between versions. If an R4 copy and an R6 copy both accept writes, two clients can change the same record at the same time through different versions. Syncing them back means conflicts, ordering problems and merge rules. With one store, every write goes to the same row, and If-Match works no matter which version the client uses.
  • One history and one audit trail. Versions, deletes and provenance are recorded once, not once per copy.
  • One place to search and analyse. Queries don't need to know which version a resource arrived in.

That leaves one design: a single store, with every version served as a projection of it. It also moves all the difficulty into one place, the conversion. It has to work in both directions, for real data, on every request.

A hackathon project

So we turned it into a hackathon project, with two goals: see how far the single-dataset design goes, and evaluate whether FHIR Mapping Language (FML) is a suitable mapping engine for this architecture.

The team: Brian Postlethwaite, Artur Kurvits, Patrick Werner, Francisco Novillo Perez, Vadim Peretokin, Ralf Pohl and Aleksandr Kislitsyn.

A special thanks to Gino Canessa. He wasn't part of the team, but he shared some of the hardest cases, the ones that are difficult to get right at all.

The setup

The server is small on purpose:

  • One store, FHIR R4 only. PostgreSQL 18, one table per resource type plus a history table. We chose R4 because that's where today's data and clients are. With R6 as the store, the same conversions would just run the other way.
  • Two APIs. /R4 reads and writes the stored JSON with no conversion. /R6 reads, searches, creates, updates and deletes through FHIR Mapping Language (FML).
  • The FML engine is Brian Postlethwaite's FML executor from FHIRPath Lab. It runs his generated R4 → R6 maps unchanged, imports and all: 164 maps. The R6 → R4 direction is 63 maps: data types reversed from the generated ones, plus hand-written maps for Patient, Encounter, BodyStructure and Observation.
  • Validation uses R4 4.0.1 and R6 6.0.0-ballot5, loaded with @atomic-ehr/fhir-canonical-manager and checked with @atomic-ehr/fhirschema.

One rule holds it together: every write, from either version, must be valid in both versions and survive the round trip R6 → R4 → R6.

Each R6 response carries the same id and versionId as the stored R4 resource, so both versions always agree on which record and which version they are looking at.

Patient: the easy case

Patient has been normative since R4 and barely changed. The generated map is a field-by-field copy, and it works unchanged. R6 adds three things to Patient.contact: role, additionalName and additionalAddress. On the way into R4 they go into cross-version extensions, and they come back out on the way to R6.

That pattern comes back throughout the project, so it's worth stating once. FHIR defines a URL scheme for cross-version extensions, http://hl7.org/fhir/[version]/StructureDefinition/extension-[element], for data that has no place in the other version. http://hl7.org/fhir/6.0/StructureDefinition/extension-Patient.contact.role is R6's Patient.contact.role, stored in R4.

Encounter: where versions really differ

An R6 client creates an Encounter with two classes and a completed status:

POST /R6/Encounter

{
  "resourceType": "Encounter",
  "status": "completed",
  "class": [
    { "coding": [{
        "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
        "code": "AMB" }] },
    { "coding": [{
        "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
        "code": "VR" }] }
  ],
  "participant": [{ "actor": { "reference": "Practitioner/dr-smith" } }],
  "actualPeriod": { "start": "2026-11-06T09:00:00Z", "end": "2026-11-06T09:30:00Z" },
  "admission": { "admitSource": { "text": "Referral" } }
}

The hand-written Encounter_6to4.fml turns it into R4. Status codes follow HL7's R5 → R4 ConceptMap, since R5 and R6 share them. The first class becomes the R4 class, every further class becomes a cross-version extension, and the renamed elements get their R4 names back:

conceptmap "EncounterStatus" {
  s:"planned"   - t:"planned"
  s:"on-hold"   - t:"onleave"
  s:"completed" - t:"finished"
  …
}

group Encounter(source src : EncounterR6, target tgt : EncounterR4) extends DomainResource <<type+>> {
  src.status as v where ($this != 'in-progress' and $this != 'discharged')
    -> tgt.status = translate(v, '#EncounterStatus', 'code');
  src.class first as c -> tgt.class as k then EncounterFirstClass(c, k);
  src.class not_first as v -> tgt.extension as x,
    x.url = 'http://hl7.org/fhir/6.0/StructureDefinition/extension-Encounter.class',
    x.valueCodeableConcept as t then CodeableConcept(v, t);
  src.actualPeriod -> tgt.period;                       // renamed
  …
}

This is what gets stored, and what an R4 client reads:

{
  "resourceType": "Encounter",
  "status": "finished",
  "class": {
    "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
    "code": "AMB"
  },
  "extension": [{
    "url": "http://hl7.org/fhir/6.0/StructureDefinition/extension-Encounter.class",
    "valueCodeableConcept": {
      "coding": [{
        "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
        "code": "VR"
      }]
    }
  }],
  "participant": [{ "individual": { "reference": "Practitioner/dr-smith" } }],
  "period": { "start": "2026-11-06T09:00:00Z", "end": "2026-11-06T09:30:00Z" },
  "hospitalization": { "admitSource": { "text": "Referral" } }
}

Reading it back through /R6 returns exactly what the client sent, plus id and meta. The round-trip check guarantees that before the write is accepted.

Is the status translation lossy?

translate() on its own is only safe where the codes match one to one. R6 has more Encounter status codes than R4, so some of them collapse:

R6 statusR4 statusLossy with translate() alone?
completedfinishedNo
on-holdonleaveNo
dischargedfinishedYes: finished reads back as completed
discontinuedno R4 codeYes: nothing to translate to

For a many-to-one case like discharged, the map stores the closest R4 code and keeps the original R6 code in a cross-version extension on the element itself. That's HL7's rule for required bindings with extra codes:

{
  "status": "finished",
  "_status": {
    "extension": [{
      "url": "http://hl7.org/fhir/6.0/StructureDefinition/extension-Encounter.status",
      "valueCode": "discharged"
    }]
  }
}

An R4 client sees a valid R4 status. The R6 direction checks the extension first, so discharged comes back as discharged, not completed. So the general approach is: translate where the codes match, and where they don't, keep the original value next to the translated one.

discontinued isn't solved yet. It has no R4 code at all, so for now a write that uses it is rejected rather than stored with a guessed status.

Search means different things in each version

The stored Encounter now has class AMB natively and VR in an extension. Which searches should find it by VR?

An R6 client sends class AMB and VR. R4 stores AMB natively and VR in the cross-version extension. R4 class matches only AMB; R4 class-r6 and R6 class match both.
The same stored row answers three searches differently.

For R6 the answer is easy: class is a list, VR is in it, so class=VR matches. The question is what R4 class should do.

Option 1: give R4 class the R6 meaning. We could have made the R4 parameter also look into the cross-version extension. Then both versions would return the same Encounters for the same query. But look at what an R4 client gets back: it asks for class=VR, and receives an Encounter whose class is AMB. The value it searched for isn't in the field the parameter is named after. It's in an extension the client may not even read. The search would contradict the data shape the client sees.

Option 2: keep the standard meaning. R4 class matches only what is in R4 class, so it finds the Encounter by AMB and not by VR. The results are consistent with the resource the R4 client receives, and standard parameters keep their published definition. The cost: the same stored data answers the same question differently depending on the version you ask through.

We chose option 2, and added an explicit parameter for R4 clients that want the R6 behaviour:

  • R6 class matches AMB and VR: the standard R6 meaning.
  • R4 class matches AMB only: the standard R4 meaning.
  • R4 class-r6 matches AMB and VR: the R6 meaning, opted into by name, returning R4 resources.

Is that the best answer? I'm not sure there is one. An R4 app that displays what is in class wants option 2. A report that has to count every virtual visit, whichever version it was written in, wants the R6 meaning. Each implementer will have to decide for their own use case. What matters is to pick deliberately, and to make the choice visible to clients rather than hiding it inside a standard parameter.

We defined class-r6 as a proper R4 SearchParameter in a small local FHIR package. The canonical manager loads it next to R4 core, the server serves it at /R4/SearchParameter/Encounter-class-r6, and the CapabilityStatement points to it:

{
  "resourceType": "SearchParameter",
  "url": "http://example.org/fhir/SearchParameter/Encounter-class-r6",
  "derivedFrom": "http://hl7.org/fhir/SearchParameter/Encounter-class",
  "code": "class-r6",
  "base": ["Encounter"],
  "type": "token",
  "expression": "Encounter.class | Encounter.class.extension('http://hl7.org/fhir/6.0/StructureDefinition/extension-Encounter.class').value | Encounter.extension('http://hl7.org/fhir/6.0/StructureDefinition/extension-Encounter.class').value"
}

The search itself runs as SQL on the stored R4. FML only converts the results for /R6.

BodyStructure: one resource, several rows

BodyStructure is where the two models stop lining up. In R6, includedStructure is a list: one BodyStructure can describe a lesion across the forearm, the upper arm and the shoulder. R4 has one location and one morphology.

Our answer, which is our own design and not HL7's, is to store one R6 BodyStructure as one R4 resource per structure, with ids abc, abc--2, abc--3. A server-managed extension links them: the first row lists the others, and each other row points back to the first.

GET /R4/BodyStructure/abc

{
  "resourceType": "BodyStructure",
  "id": "abc",
  "location": { "text": "Forearm" },
  "extension": [{
    "url": "http://example.org/fhir/StructureDefinition/bodystructure-row-group",
    "extension": [
      { "url": "position", "valuePositiveInt": 1 },
      { "url": "member", "valueReference": { "reference": "BodyStructure/abc--2" } },
      { "url": "member", "valueReference": { "reference": "BodyStructure/abc--3" } }
    ]
  }]
}

The rest follows from that:

  • A write rewrites the whole set of rows in one transaction, from either version.
  • R6-only values go into cross-version extensions, such as extra morphologies, laterality and landmarks.
  • Row ids stay stable, and each structure's R6 includedStructure.id is its row number.
  • R6 only ever shows abc. GET /R6/BodyStructure/abc--2 is a 404.

There is another way to split, and it may well be the clearer one. Number every structure, abc--1, abc--2, abc--3, and don't store an R4 resource called abc at all. A read of GET /R4/BodyStructure/abc then returns an OperationOutcome that lists the real ids:

GET /R4/BodyStructure/abc

{
  "resourceType": "OperationOutcome",
  "issue": [{
    "severity": "information",
    "code": "informational",
    "diagnostics": "BodyStructure/abc is stored in R4 as three resources: BodyStructure/abc--1, BodyStructure/abc--2, BodyStructure/abc--3"
  }]
}

The trade-offs run in both directions:

abc, abc--2, abc--3 (what we built)abc--1, abc--2, abc--3 + OperationOutcome
Reading abc through R4Returns the first structure, which looks like the whole thingSays plainly that it's split, and where the parts are
Existing R4 references to abcKeep working, and point at the first structurePoint at something you can't read as a BodyStructure
Removing the first structureThe next one moves into abc, so references must be rewrittenNothing moves: each structure keeps its id
R4 clientsCan ignore the split until it mattersHave to understand the OperationOutcome from day one

We went with keeping abc as a real resource, mainly so existing R4 references and clients keep working. As with the search semantics, I don't think there is one right answer here. Each system will have to decide based on its own clients.

References to a split resource

Splitting a resource gives it two identities: R4 clients see rows, R6 clients see one resource. Every resource that points at a BodyStructure now has to be translated between the two.

In R6, an Observation points at a BodyStructure through bodyStructure. R4 has no such element, so the standard http://hl7.org/fhir/StructureDefinition/bodySite extension carries the link. Since one R6 BodyStructure is several R4 rows, a single link isn't enough: in R4, BodyStructure/abc is only the forearm. So the R6 → R4 map resolves the reference and writes one link per row:

Writing through R6, one bodyStructure reference is resolved and stored as one bodySite link per R4 row. Reading through R6, the links merge back into one reference. Rules: keep, repoint, refuse.
One R6 reference becomes one R4 link per row, and back.

bs.reference as r where (reference.startsWith('BodyStructure/'))
  -> evaluate(r, resolve() as BodyStructure) as row
  then BodyStructureRows(row, t);

group BodyStructureRows(source row : BodyStructure, target t) {
  row.extension as g where (url = '…/bodystructure-row-group') then {
    g.extension as m where (url = 'member') then {
      m.value : Reference as member -> t.extension as x,
        x.url = 'http://hl7.org/fhir/StructureDefinition/bodySite',
        x.valueReference as v then Reference(member, v);
    };
  };
}

On the way back, Observation_4to6 strips the --N suffix and the duplicates merge into the single reference the client sent. Three server rules keep it consistent:

  • Keep. If an R4 client narrowed the link to one row, an R6 update keeps that narrowing, because R6 can't express it.
  • Repoint. If the first structure is removed, the next one moves into the first row, and every Observation that pointed at the old row is rewritten in the same transaction.
  • Refuse. Anything that doesn't survive R6 → R4 → R6, such as a bodyStructure given as a concept, is rejected with 422.

What it means for existing R4 clients

One dataset doesn't mean R4 clients are untouched. They will probably need some changes:

  • A read can be partial. GET /R4/BodyStructure/abc returns only the first structure. The rest are in rows a client finds only through the row-group extension.
  • R6 data arrives as extensions. Extra Encounter classes and Observation statusReason, for example, are cross-version extensions. A client that ignores them misses data, and one that strips them on update relies on the server carrying them forward (ours does).
  • Search finds less. R4 class doesn't match a class that exists only in R6. The client has to know about class-r6.
  • Data changes under them. References point at rows that can move, and the server rewrites Observations when they do. Versions change without the client writing, so If-Match conflicts become more likely.

Takeaways

1. It's a modelling exercise more than a technical one

The technical side held up. One FML engine ran the generated R4 → R6 maps unchanged, and hand-written maps covered renames, code translation, lists folded into one value plus extensions, and one resource split into several. FML is a suitable mapping engine for this architecture. The canonical manager, fhirschema and PostgreSQL did their jobs.

The hard questions were all about the model, and the spec won't answer them for you. R4 and R6 offer different shapes for the same data: one class or a list of classes, one body structure or several, a coded body site or a reference. Deciding how one shape becomes the other depends on what the data means in your system, so your own business modelling ends up in the mappings. No version of the spec will make those decisions for you.

2. The modelling has to stay changeable

Those decisions will change. HL7's cross-version work will publish conventions, your clients will push back, and some choices will turn out wrong. So the rules need to be visible and reversible, not buried in code: maps instead of hand-written conversions, search parameters that are published definitions, and a documented list of what goes where.

There is a catch: because the store is R4, every modelling decision is also a storage format. We changed the URL of the Encounter class extension once during the hackathon, and Encounters already stored under the old URL quietly lost their extra class in the R6 view. Reversing a decision means migrating the data written under it, so plan for that from the start.

3. R4 clients won't get away without changes

It's tempting to think of cross-version extensions as extra data an R4 client can safely ignore. They aren't. What ends up in them is often the core of the record: the link from an Observation to the body structures it describes, the second class of an Encounter, the rows of a BodyStructure beyond the first. A client that ignores them doesn't just miss detail. It gets the wrong answer, and its searches behave differently from the R6 ones. Existing R4 clients will need at least some changes to read the data correctly.

Try it

Share this article
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

Get the latest articles on FHIR, interoperability, and healthcare IT.