|
17 min de lecture
|

Un seul dépôt FHIR, deux versions : servir R4 et R6 avec FML

Résumé de l'article

R4 et R6 coexisteront pendant des années, et au FHIR Camp 2026, les praticiens qui vivent avec cette réalité étaient clairs : ils veulent un seul jeu de données, pas une copie par version. Nous avons donc conçu un serveur qui stocke FHIR R4 une seule fois et sert R6 via des cartes FML dans les deux sens. Patient est converti sans modification. Encounter nécessitait des renommages, une traduction de statuts et des extensions inter-versions. La recherche avait besoin d'un second paramètre. BodyStructure devait être scindé en plusieurs ressources R4, ce qui brisait les références jusqu'à ce que nous corrigions le problème. Les outils ont rempli leur rôle. Chaque décision difficile concernait le modèle.

Résumer cet article avec :
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.
Un seul dépôt R4, deux API. R6 est calculé à partir des données R4 stockées à chaque requête.

Pourquoi un seul serveur pour deux versions

R6 en est à ses derniers scrutins, et la question a changé. Ce n'est plus « quoi de neuf dans R6 », mais « combien de temps le ferons-nous coexister avec R4 ». Tout ce que nous savons indique : pendant des années.

  • La réglementation fixe les versions. La certification américaine (US Core) et les règles d'interopérabilité de CMS pour l'accès aux patients, l'accès aux prestataires, l'échange entre payeurs et l'autorisation préalable reposent toutes sur R4. Les nouvelles règles viendront avec des fenêtres de transition, et non une date de bascule. Les autres pays avancent selon leurs propres échéanciers.
  • Les partenaires ne font pas leur mise à niveau en même temps. Si vous êtes une plateforme, un HIE ou un payeur, vous héritez de l'union de toutes les versions de vos partenaires.
  • Les longues queues de migration sont normales. Epic sert encore DSTU2, STU3 et R4, chacun à sa propre URL de base. Oracle Health aurait fait fonctionner DSTU2 aux côtés de R4 pendant environ six ans avant de le retirer en 2025.
  • R5 a été largement sauté. Les systèmes en production sont restés sur R4 et ont adopté les fonctionnalités R5 dont ils avaient besoin, comme les abonnements basés sur les sujets, via des IGs de rétrocompatibilité R4. Le saut réaliste est donc R4 directement vers R6, avec tous les changements R4 → R5 et R5 → R6 d'un seul coup.
  • Les données survivent aux versions. Les dossiers créés aujourd'hui seront consultés dans dix ou vingt ans. Les archives et les pistes d'audit ne sont pas réécrites.

Les changements ne sont pas non plus cosmétiques. Quelques-uns que nous avons rencontrés :

R4R6Pourquoi c'est problématique
Encounter.class : un seul CodingEncounter.class : une liste de CodeableConceptPlusieurs classes ne tiennent pas dans une seule
Encounter.status finished, onleave, arrivedcompleted, on-hold, in-progress + subjectStatusCodes différents, sans correspondance biunivoque
Encounter.period, hospitalization, participant.individualactualPeriod, admission, participant.actorRenommages
BodyStructure.location et morphology : un de chaqueBodyStructure.includedStructure : un ou plusieursUne ressource R6 peut décrire ce que R4 nécessite plusieurs ressources pour représenter
Observation.bodySite : un codeajoute Observation.bodyStructure : une référenceR4 n'a pas d'élément pour stocker le lien

La coexistence est donc inévitable. La vraie question est de savoir comment la gérer au sein d'un seul système.

Ce que la salle a dit au FHIR Camp

Au HL7 FHIR Camp 2026 à Cascais, au Portugal, j'ai animé une session Birds-of-a-Feather intitulée Coexistence de plusieurs versions FHIR dans le même système. La question : un seul système devrait-il servir plusieurs versions à partir d'un seul jeu de données avec des convertisseurs, ou conserver une copie distincte par version, ou stocker chaque ressource dans la version dans laquelle elle est arrivée ?

La réponse de la salle était claire : les gens veulent un seul jeu de données. Les raisons sont pratiques :

  • Une seule source de vérité. Un patient existe une seule fois. Avec une copie par version, les copies divergent, et quelqu'un doit décider laquelle est correcte.
  • Pas de conflits entre versions. Si une copie R4 et une copie R6 acceptent toutes deux des écritures, deux clients peuvent modifier le même dossier en même temps via des versions différentes. Les synchroniser entraîne des conflits, des problèmes d'ordonnancement et des règles de fusion. Avec un seul dépôt, chaque écriture va dans la même rangée, et If-Match fonctionne quelle que soit la version utilisée par le client.
  • Un seul historique et une seule piste d'audit. Les versions, suppressions et provenances sont enregistrées une seule fois, pas une fois par copie.
  • Un seul endroit pour rechercher et analyser. Les requêtes n'ont pas besoin de savoir dans quelle version une ressource est arrivée.

Il ne reste qu'une conception possible : un seul dépôt, avec chaque version servie comme une projection de celui-ci. Cela concentre aussi toute la difficulté en un seul endroit : la conversion. Elle doit fonctionner dans les deux sens, sur des données réelles, à chaque requête.

Un projet de hackathon

Nous en avons donc fait un projet de hackathon, avec deux objectifs : voir jusqu'où va la conception à jeu de données unique, et évaluer si le FHIR Mapping Language (FML) est un moteur de correspondance adapté à cette architecture.

L'équipe : Brian Postlethwaite, Artur Kurvits, Patrick Werner, Francisco Novillo Perez, Vadim Peretokin, Ralf Pohl et Aleksandr Kislitsyn.

Un merci tout particulier à Gino Canessa. Il ne faisait pas partie de l'équipe, mais il a partagé certains des cas les plus difficiles, ceux qu'il est difficile de traiter correctement.

La configuration

Le serveur est intentionnellement minimaliste :

  • Un seul dépôt, FHIR R4 uniquement. PostgreSQL 18, une table par type de ressource plus une table d'historique. Nous avons choisi R4 parce que c'est là que se trouvent les données et les clients actuels. Avec R6 comme dépôt, les mêmes conversions s'effectueraient simplement dans l'autre sens.
  • Deux API. /R4 lit et écrit le JSON stocké sans conversion. /R6 lit, recherche, crée, met à jour et supprime via le FHIR Mapping Language (FML).
  • Le moteur FML est l'exécuteur FML de Brian Postlethwaite issu de FHIRPath Lab. Il exécute ses cartes R4 → R6 générées sans modification, importations comprises : 164 cartes. La direction R6 → R4 comporte 63 cartes : les types de données inversés par rapport aux cartes générées, plus des cartes écrites à la main pour Patient, Encounter, BodyStructure et Observation.
  • La validation utilise R4 4.0.1 et R6 6.0.0-ballot5, chargés avec @atomic-ehr/fhir-canonical-manager et vérifiés avec @atomic-ehr/fhirschema.

Une règle cimente le tout : chaque écriture, quelle que soit la version, doit être valide dans les deux versions et survivre à l'aller-retour R6 → R4 → R6.

Chaque réponse R6 porte le même id et versionId que la ressource R4 stockée, de sorte que les deux versions s'accordent toujours sur le dossier et la version consultés.

Patient : le cas facile

Patient est normatif depuis R4 et a peu changé. La carte générée est une copie champ par champ, et elle fonctionne sans modification. R6 ajoute trois éléments à Patient.contact : role, additionalName et additionalAddress. À l'entrée dans R4, ils sont placés dans des extensions inter-versions, et ils en ressortent à la sortie vers R6.

Ce patron revient tout au long du projet, et vaut la peine d'être énoncé une fois pour toutes. FHIR définit un schéma d'URL pour les extensions inter-versions, http://hl7.org/fhir/[version]/StructureDefinition/extension-[element], pour les données qui n'ont pas de place dans l'autre version. http://hl7.org/fhir/6.0/StructureDefinition/extension-Patient.contact.role est le Patient.contact.role de R6, stocké en R4.

Encounter : là où les versions diffèrent vraiment

Un client R6 crée un Encounter avec deux classes et un statut « completed » :

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" } }
}

Le Encounter_6to4.fml écrit à la main le transforme en R4. Les codes de statut suivent le ConceptMap R5 → R4 de HL7, puisque R5 et R6 les partagent. La première classe devient la class R4, chaque classe supplémentaire devient une extension inter-versions, et les éléments renommés retrouvent leurs noms R4 :

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
  …
}

Voici ce qui est stocké et ce qu'un client R4 lit :

{
  "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" } }
}

La relecture via /R6 retourne exactement ce que le client a envoyé, plus id et meta. La vérification de l'aller-retour le garantit avant que l'écriture soit acceptée.

La traduction de statuts est-elle avec perte ?

translate() seul n'est sûr que là où les codes se correspondent biunivoquement. R6 possède plus de codes de statut Encounter que R4, ce qui fait que certains se regroupent :

Statut R6Statut R4Avec perte avec translate() seul ?
completedfinishedNon
on-holdonleaveNon
dischargedfinishedOui : finished se relit comme completed
discontinuedaucun code R4Oui : rien vers quoi traduire

Pour un cas plusieurs-vers-un comme discharged, la carte stocke le code R4 le plus proche et conserve le code R6 original dans une extension inter-versions sur l'élément lui-même. C'est la règle HL7 pour les liaisons requises avec des codes supplémentaires :

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

Un client R4 voit un statut R4 valide. La direction R6 vérifie d'abord l'extension, de sorte que discharged revient comme discharged, et non comme completed. L'approche générale est donc : traduire là où les codes correspondent, et lorsqu'ils ne correspondent pas, conserver la valeur d'origine à côté de la valeur traduite.

discontinued n'est pas encore résolu. Il n'a aucun code R4, donc pour l'instant une écriture qui l'utilise est rejetée plutôt que stockée avec un statut deviné.

La recherche a un sens différent selon la version

L'Encounter stocké a maintenant la classe AMB de façon native et VR dans une extension. Quelles recherches devraient le trouver par 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.
La même rangée stockée répond différemment à trois recherches.

Pour R6, la réponse est simple : class est une liste, VR en fait partie, donc class=VR correspond. La question est ce que class en R4 devrait faire.

Option 1 : donner à class R4 la signification R6. Nous aurions pu faire en sorte que le paramètre R4 cherche aussi dans l'extension inter-versions. Les deux versions retourneraient alors les mêmes Encounters pour la même requête. Mais voyez ce qu'un client R4 reçoit : il demande class=VR, et reçoit un Encounter dont la class est AMB. La valeur recherchée ne figure pas dans le champ que porte le nom du paramètre. Elle est dans une extension que le client ne lit peut-être même pas. La recherche contredirait la forme des données que le client voit.

Option 2 : conserver la signification standard. class en R4 ne correspond qu'à ce qui figure dans class R4, donc il trouve l'Encounter par AMB et non par VR. Les résultats sont cohérents avec la ressource que reçoit le client R4, et les paramètres standard conservent leur définition publiée. Le coût : les mêmes données stockées répondent différemment à la même question selon la version par laquelle on les interroge.

Nous avons choisi l'option 2, et avons ajouté un paramètre explicite pour les clients R4 qui veulent le comportement R6 :

  • R6 class correspond à AMB et VR : la signification R6 standard.
  • R4 class correspond uniquement à AMB : la signification R4 standard.
  • R4 class-r6 correspond à AMB et VR : la signification R6, optée explicitement par son nom, retournant des ressources R4.

Est-ce la meilleure réponse ? Je ne suis pas sûr qu'il en existe une. Une application R4 qui affiche ce qui est dans class veut l'option 2. Un rapport qui doit comptabiliser chaque visite virtuelle, quelle que soit la version dans laquelle elle a été écrite, veut la signification R6. Chaque implémenteur devra décider selon son propre cas d'utilisation. Ce qui importe, c'est de choisir délibérément et de rendre le choix visible aux clients plutôt que de le cacher dans un paramètre standard.

Nous avons défini class-r6 comme un SearchParameter R4 approprié dans un petit paquet FHIR local. Le gestionnaire canonique le charge aux côtés du noyau R4, le serveur le sert à /R4/SearchParameter/Encounter-class-r6, et le CapabilityStatement y pointe :

{
  "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"
}

La recherche elle-même s'exécute en SQL sur le R4 stocké. FML ne convertit que les résultats pour /R6.

BodyStructure : une ressource, plusieurs rangées

BodyStructure est l'endroit où les deux modèles cessent de s'aligner. Dans R6, includedStructure est une liste : un seul BodyStructure peut décrire une lésion couvrant l'avant-bras, le bras supérieur et l'épaule. R4 n'a qu'un seul location et un seul morphology.

Notre réponse, qui est notre propre conception et non celle de HL7, est de stocker un BodyStructure R6 en tant que une ressource R4 par structure, avec les identifiants abc, abc--2, abc--3. Une extension gérée par le serveur les relie : la première rangée liste les autres, et chaque autre rangée pointe vers la première.

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" } }
    ]
  }]
}

Le reste en découle :

  • Une écriture réécrit l'ensemble des rangées en une seule transaction, quelle que soit la version.
  • Les valeurs propres à R6 vont dans des extensions inter-versions, comme les morphologies supplémentaires, la latéralité et les repères anatomiques.
  • Les identifiants de rangée restent stables, et l'includedStructure.id R6 de chaque structure correspond à son numéro de rangée.
  • R6 ne montre que abc. GET /R6/BodyStructure/abc--2 retourne un 404.

Il existe une autre façon de scinder, qui est peut-être la plus claire. Numéroter chaque structure, abc--1, abc--2, abc--3, et ne pas stocker de ressource R4 appelée abc du tout. Une lecture de GET /R4/BodyStructure/abc retourne alors un OperationOutcome qui liste les véritables identifiants :

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"
  }]
}

Les compromis jouent dans les deux sens :

abc, abc--2, abc--3 (ce que nous avons construit)abc--1, abc--2, abc--3 + OperationOutcome
Lecture de abc via R4Retourne la première structure, qui ressemble à l'ensembleIndique clairement que c'est scindé, et où se trouvent les parties
Références R4 existantes vers abcContinuent de fonctionner et pointent vers la première structurePointent vers quelque chose qu'on ne peut pas lire comme BodyStructure
Suppression de la première structureLa suivante prend la place de abc, donc les références doivent être réécritesRien ne bouge : chaque structure conserve son identifiant
Clients R4Peuvent ignorer la scission jusqu'à ce que cela importeDoivent comprendre le OperationOutcome dès le premier jour

Nous avons opté pour conserver abc comme ressource réelle, principalement pour que les références et clients R4 existants continuent de fonctionner. Comme pour la sémantique des recherches, je ne pense pas qu'il y ait une seule bonne réponse ici. Chaque système devra décider selon ses propres clients.

Références vers une ressource scindée

Scinder une ressource lui donne deux identités : les clients R4 voient des rangées, les clients R6 voient une ressource unique. Chaque ressource qui pointe vers un BodyStructure doit maintenant être traduite entre les deux.

Dans R6, un Observation pointe vers un BodyStructure via bodyStructure. R4 n'a pas un tel élément, donc l'extension standard http://hl7.org/fhir/StructureDefinition/bodySite porte le lien. Comme un BodyStructure R6 correspond à plusieurs rangées R4, un seul lien ne suffit pas : en R4, BodyStructure/abc est uniquement l'avant-bras. Donc la carte R6 → R4 résout la référence et écrit un lien par rangée :

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.
Une référence R6 devient un lien R4 par rangée, puis revient.

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);
    };
  };
}

Au retour, Observation_4to6 supprime le suffixe --N et les doublons fusionnent en la référence unique que le client a envoyée. Trois règles du serveur assurent la cohérence :

  • Conserver. Si un client R4 a restreint le lien à une seule rangée, une mise à jour R6 conserve cette restriction, car R6 ne peut pas l'exprimer.
  • Réorienter. Si la première structure est supprimée, la suivante prend la place de la première rangée, et chaque Observation qui pointait vers l'ancienne rangée est réécrite dans la même transaction.
  • Refuser. Tout ce qui ne survit pas à l'aller-retour R6 → R4 → R6, comme un bodyStructure fourni sous forme de concept, est rejeté avec 422.

Ce que cela signifie pour les clients R4 existants

Un seul jeu de données ne signifie pas que les clients R4 sont inchangés. Ils auront probablement besoin de quelques modifications :

  • Une lecture peut être partielle. GET /R4/BodyStructure/abc ne retourne que la première structure. Les autres sont dans des rangées qu'un client ne trouve qu'à travers l'extension row-group.
  • Les données R6 arrivent sous forme d'extensions. Les classes d'Encounter supplémentaires et le statusReason d'Observation, par exemple, sont des extensions inter-versions. Un client qui les ignore manque des données, et celui qui les supprime lors d'une mise à jour s'en remet au serveur pour les conserver (le nôtre le fait).
  • La recherche trouve moins. R4 class ne correspond pas à une classe qui n'existe que dans R6. Le client doit connaître class-r6.
  • Les données changent sous leurs pieds. Les références pointent vers des rangées qui peuvent bouger, et le serveur réécrit les Observations lorsque c'est le cas. Les versions changent sans que le client n'écrive, donc les conflits If-Match deviennent plus probables.

Conclusions

1. C'est davantage un exercice de modélisation que technique

L'aspect technique a tenu bon. Un seul moteur FML a exécuté les cartes R4 → R6 générées sans modification, et des cartes écrites à la main ont couvert les renommages, la traduction de codes, les listes repliées en une seule valeur plus des extensions, et une ressource scindée en plusieurs. FML est un moteur de correspondance adapté à cette architecture. Le gestionnaire canonique, fhirschema et PostgreSQL ont fait leur travail.

Les questions difficiles portaient toutes sur le modèle, et la spécification ne vous répondra pas à votre place. R4 et R6 offrent des formes différentes pour les mêmes données : une classe ou une liste de classes, un seul body structure ou plusieurs, un site corporel codé ou une référence. Décider comment une forme devient l'autre dépend de ce que les données signifient dans votre système, ce qui fait que votre propre modélisation métier se retrouve dans les correspondances. Aucune version de la spécification ne prendra ces décisions à votre place.

2. La modélisation doit rester modifiable

Ces décisions changeront. Les travaux inter-versions de HL7 publieront des conventions, vos clients pousseront dans d'autres directions, et certains choix s'avéreront erronés. Les règles doivent donc être visibles et réversibles, et non enfouies dans le code : des cartes plutôt que des conversions écrites à la main, des paramètres de recherche qui sont des définitions publiées, et une liste documentée de ce qui va où.

Il y a un piège : parce que le dépôt est R4, chaque décision de modélisation est aussi un format de stockage. Nous avons modifié une fois l'URL de l'extension de classe Encounter pendant le hackathon, et les Encounters déjà stockés sous l'ancienne URL ont silencieusement perdu leur classe supplémentaire dans la vue R6. Revenir sur une décision signifie migrer les données écrites sous celle-ci, alors planifiez-le dès le départ.

3. Les clients R4 ne s'en tireront pas sans modifications

Il est tentant de penser aux extensions inter-versions comme à des données supplémentaires qu'un client R4 peut ignorer sans risque. Ce n'est pas le cas. Ce qui s'y retrouve est souvent le cœur du dossier : le lien d'un Observation vers les structures corporelles qu'il décrit, la deuxième classe d'un Encounter, les rangées d'un BodyStructure au-delà de la première. Un client qui les ignore ne manque pas seulement un détail. Il obtient une mauvaise réponse, et ses recherches se comportent différemment de celles en R6. Les clients R4 existants auront besoin d'au moins quelques modifications pour lire les données correctement.

Essayez-le

Partager cet article
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

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