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:
| R4 | R6 | Why it hurts |
|---|---|---|
Encounter.class: one Coding | Encounter.class: a list of CodeableConcept | Several classes don't fit in one |
Encounter.status finished, onleave, arrived | completed, on-hold, in-progress + subjectStatus | Different codes, not one-to-one |
Encounter.period, hospitalization, participant.individual | actualPeriod, admission, participant.actor | Renames |
BodyStructure.location and morphology: one each | BodyStructure.includedStructure: one or more | One R6 resource can describe what R4 needs several resources for |
Observation.bodySite: a code | adds Observation.bodyStructure: a reference | R4 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-Matchworks 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.
/R4reads and writes the stored JSON with no conversion./R6reads, 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-managerand 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 status | R4 status | Lossy with translate() alone? |
|---|---|---|
completed | finished | No |
on-hold | onleave | No |
discharged | finished | Yes: finished reads back as completed |
discontinued | no R4 code | Yes: 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?
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
classmatchesAMBandVR: the standard R6 meaning. - R4
classmatchesAMBonly: the standard R4 meaning. - R4
class-r6matchesAMBandVR: 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.idis its row number. - R6 only ever shows
abc.GET /R6/BodyStructure/abc--2is a404.
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 R4 | Returns the first structure, which looks like the whole thing | Says plainly that it's split, and where the parts are |
Existing R4 references to abc | Keep working, and point at the first structure | Point at something you can't read as a BodyStructure |
| Removing the first structure | The next one moves into abc, so references must be rewritten | Nothing moves: each structure keeps its id |
| R4 clients | Can ignore the split until it matters | Have 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:
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
bodyStructuregiven as a concept, is rejected with422.
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/abcreturns 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
classdoesn't match a class that exists only in R6. The client has to know aboutclass-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-Matchconflicts 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
- Code, maps and tests: github.com/aleksandrkislitsyn/fml-r4-r6-fhir-server. The README explains how to run it, and
docs/mappings.htmldocuments every mapping decision. - More on R6: FHIR R6 release date and what's new, FHIR R4 vs R5 vs R6, R6: Subtraction by Additional Resources and R6: What Happens to a Reference When Its Target Goes to an Incubator.





