|
17 min de lectura
|

Un almacén FHIR, dos versiones: sirviendo R4 y R6 con FML

Resumen del artículo

R4 y R6 coexistirán durante años, y en el FHIR Camp 2026 quienes trabajan con ello fueron claros: quieren un único conjunto de datos, no una copia por versión. Así que construimos un servidor que almacena FHIR R4 una sola vez y sirve R6 mediante mapas FML en ambas direcciones. Patient se convirtió sin cambios. Encounter necesitó renombrados, traducción de estados y extensiones entre versiones. La búsqueda requirió un segundo parámetro. BodyStructure tuvo que dividirse en varios recursos R4, lo que rompió las referencias hasta que las corregimos. Las herramientas hicieron su trabajo. Cada decisión difícil fue sobre el modelo.

Resumir este artículo con:
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 almacén R4, dos APIs. R6 se calcula a partir del R4 almacenado en cada petición.

Por qué un único servidor para dos versiones

R6 está en sus últimas votaciones, y la pregunta ha cambiado. Ya no es «qué hay de nuevo en R6», sino «cuánto tiempo lo ejecutaremos junto a R4». Todo lo que sabemos indica que: durante años.

  • La regulación fija las versiones. La certificación estadounidense (US Core) y las reglas de interoperabilidad de CMS para Patient Access, Provider Access, Payer-to-Payer y Prior Authorization se basan todas en R4. Las nuevas normas vendrán con períodos de transición, no con una fecha de corte. Otras jurisdicciones avanzan según sus propios calendarios.
  • Los socios no actualizan al mismo tiempo. Si usted es una plataforma, un HIE o un pagador, recibirá la unión de las versiones de todos.
  • Las colas largas son normales. Epic todavía sirve DSTU2, STU3 y R4, cada uno en su propia URL base. Oracle Health supuestamente ejecutó DSTU2 junto a R4 durante unos seis años antes de retirarlo en 2025.
  • R5 fue mayoritariamente omitido. Los sistemas en producción se quedaron en R4 y adoptaron las funcionalidades de R5 que necesitaban, como las Subscriptions basadas en temas, mediante IGs de backport para R4. Por tanto, el salto realista es de R4 directamente a R6, con todos los cambios de R4 → R5 y R5 → R6 de una vez.
  • Los datos sobreviven a las versiones. Los registros escritos hoy se leerán en diez o veinte años. Los archivos históricos y las pistas de auditoría no se reescriben.

Los cambios tampoco son cosméticos. Algunos con los que nos encontramos:

R4R6Por qué es problemático
Encounter.class: un CodingEncounter.class: una lista de CodeableConceptVarias clases no caben en una
Encounter.status finished, onleave, arrivedcompleted, on-hold, in-progress + subjectStatusCódigos distintos, sin correspondencia uno a uno
Encounter.period, hospitalization, participant.individualactualPeriod, admission, participant.actorRenombrados
BodyStructure.location y morphology: uno de cadaBodyStructure.includedStructure: uno o másUn recurso R6 puede describir lo que R4 necesita varios recursos para representar
Observation.bodySite: un códigoañade Observation.bodyStructure: una referenciaR4 no tiene ningún elemento para almacenar el enlace

La coexistencia, pues, está garantizada. La verdadera pregunta es cómo llevarla a cabo dentro de un único sistema.

Lo que dijo la sala en el FHIR Camp

En el HL7 FHIR Camp 2026 en Cascais, Portugal, organicé una sesión Birds-of-a-Feather titulada Coexistencia de múltiples versiones FHIR en el mismo sistema. La pregunta: ¿debe un sistema servir varias versiones desde un único conjunto de datos con conversores, mantener una copia separada por versión, o almacenar cada recurso en la versión en que llegó?

La respuesta de la sala fue clara: la gente quiere un único conjunto de datos. Las razones son prácticas:

  • Una única fuente de verdad. Un paciente existe una sola vez. Con una copia por versión, las copias divergen y alguien tiene que decidir cuál es la correcta.
  • Sin carreras entre versiones. Si una copia R4 y una copia R6 aceptan escrituras simultáneamente, dos clientes pueden modificar el mismo registro al mismo tiempo a través de versiones distintas. Sincronizarlos implica conflictos, problemas de ordenación y reglas de fusión. Con un único almacén, cada escritura va a la misma fila e If-Match funciona independientemente de la versión que utilice el cliente.
  • Un único historial y una única pista de auditoría. Las versiones, eliminaciones y la provenance se registran una sola vez, no una vez por copia.
  • Un único lugar para buscar y analizar. Las consultas no necesitan saber en qué versión llegó un recurso.

Eso deja un único diseño: un almacén único, con cada versión servida como una proyección del mismo. También concentra toda la dificultad en un solo lugar: la conversión. Esta debe funcionar en ambas direcciones, con datos reales, en cada petición.

Un proyecto de hackathon

Así que lo convertimos en un proyecto de hackathon, con dos objetivos: ver hasta dónde llega el diseño de conjunto de datos único, y evaluar si FHIR Mapping Language (FML) es un motor de mapeo adecuado para esta arquitectura.

El equipo: Brian Postlethwaite, Artur Kurvits, Patrick Werner, Francisco Novillo Perez, Vadim Peretokin, Ralf Pohl y Aleksandr Kislitsyn.

Un agradecimiento especial a Gino Canessa. No formaba parte del equipo, pero compartió algunos de los casos más difíciles, los que son complicados de resolver correctamente.

La configuración

El servidor es pequeño a propósito:

  • Un almacén, solo FHIR R4. PostgreSQL 18, una tabla por tipo de recurso más una tabla de historial. Elegimos R4 porque ahí es donde están los datos y los clientes actuales. Con R6 como almacén, las mismas conversiones simplemente se ejecutarían en sentido inverso.
  • Dos APIs. /R4 lee y escribe el JSON almacenado sin ninguna conversión. /R6 lee, busca, crea, actualiza y elimina mediante FHIR Mapping Language (FML).
  • El motor FML es el ejecutor FML de Brian Postlethwaite de FHIRPath Lab. Ejecuta sus mapas generados de R4 → R6 sin modificación, importaciones incluidas: 164 mapas. La dirección R6 → R4 consta de 63 mapas: tipos de datos invertidos respecto a los generados, más mapas escritos a mano para Patient, Encounter, BodyStructure y Observation.
  • La validación utiliza R4 4.0.1 y R6 6.0.0-ballot5, cargados con @atomic-ehr/fhir-canonical-manager y verificados con @atomic-ehr/fhirschema.

Una regla lo mantiene todo cohesionado: cada escritura, desde cualquier versión, debe ser válida en ambas versiones y superar el viaje de ida y vuelta R6 → R4 → R6.

Cada respuesta R6 lleva el mismo id y versionId que el recurso R4 almacenado, de modo que ambas versiones siempre coinciden en qué registro y qué versión están consultando.

Patient: el caso sencillo

Patient ha sido normativo desde R4 y apenas ha cambiado. El mapa generado es una copia campo a campo, y funciona sin modificaciones. R6 añade tres elementos a Patient.contact: role, additionalName y additionalAddress. En el camino hacia R4, se almacenan en extensiones entre versiones, y vuelven a salir en el camino hacia R6.

Este patrón se repite a lo largo del proyecto, por lo que merece enunciarse una vez. FHIR define un esquema de URL para las extensiones entre versiones, http://hl7.org/fhir/[version]/StructureDefinition/extension-[element], para datos que no tienen cabida en la otra versión. http://hl7.org/fhir/6.0/StructureDefinition/extension-Patient.contact.role es el Patient.contact.role de R6, almacenado en R4.

Encounter: donde las versiones realmente difieren

Un cliente R6 crea un Encounter con dos clases y un estado 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" } }
}

El Encounter_6to4.fml escrito a mano lo convierte a R4. Los códigos de estado siguen el ConceptMap R5 → R4 de HL7, ya que R5 y R6 los comparten. La primera clase se convierte en el class de R4, cada clase adicional se convierte en una extensión entre versiones, y los elementos renombrados recuperan sus nombres en 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
  …
}

Esto es lo que se almacena y lo que lee un cliente R4:

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

Leerlo de vuelta a través de /R6 devuelve exactamente lo que el cliente envió, más id y meta. La verificación del viaje de ida y vuelta lo garantiza antes de aceptar la escritura.

¿Es la traducción de estados con pérdida de información?

translate() por sí solo solo es seguro cuando los códigos se corresponden uno a uno. R6 tiene más códigos de estado para Encounter que R4, por lo que algunos de ellos se colapsan:

Estado R6Estado R4¿Con pérdida usando solo translate()?
completedfinishedNo
on-holdonleaveNo
dischargedfinishedSí: finished se lee de vuelta como completed
discontinuedsin código R4Sí: no hay nada a lo que traducirlo

Para un caso de muchos a uno como discharged, el mapa almacena el código R4 más cercano y conserva el código R6 original en una extensión entre versiones sobre el propio elemento. Esa es la regla de HL7 para los bindings requeridos con códigos adicionales:

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

Un cliente R4 ve un estado R4 válido. La dirección R6 comprueba primero la extensión, de modo que discharged vuelve como discharged, no como completed. Así pues, el enfoque general es: traducir donde los códigos coincidan y, donde no coincidan, conservar el valor original junto al traducido.

discontinued aún no está resuelto. No tiene ningún código R4, así que por ahora se rechaza una escritura que lo utilice en lugar de almacenarla con un estado inferido.

Búsqueda significa cosas diferentes en cada versión

El Encounter almacenado ahora tiene la clase AMB de forma nativa y VR en una extensión. ¿Qué búsquedas deberían encontrarlo por 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 misma fila almacenada responde de forma diferente a tres búsquedas distintas.

Para R6 la respuesta es sencilla: class es una lista, VR está en ella, así que class=VR coincide. La pregunta es qué debería hacer R4 class.

Opción 1: dar a R4 class el significado de R6. Podríamos haber hecho que el parámetro R4 también buscara en la extensión entre versiones. Entonces ambas versiones devolverían los mismos Encounters para la misma consulta. Pero fíjese en lo que recibe un cliente R4: pregunta por class=VR y recibe un Encounter cuyo class es AMB. El valor que buscó no está en el campo al que hace referencia el nombre del parámetro, sino en una extensión que el cliente puede no llegar a leer. La búsqueda contradiría la forma de los datos que ve el cliente.

Opción 2: mantener el significado estándar. R4 class solo coincide con lo que está en R4 class, por lo que encuentra el Encounter por AMB pero no por VR. Los resultados son coherentes con el recurso que recibe el cliente R4, y los parámetros estándar conservan su definición publicada. El coste: los mismos datos almacenados responden a la misma pregunta de forma diferente según la versión a través de la que se consulten.

Elegimos la opción 2 y añadimos un parámetro explícito para los clientes R4 que quieran el comportamiento de R6:

  • R6 class coincide con AMB y VR: el significado estándar de R6.
  • R4 class coincide solo con AMB: el significado estándar de R4.
  • R4 class-r6 coincide con AMB y VR: el significado de R6, activado explícitamente por nombre, devolviendo recursos R4.

¿Es esa la mejor respuesta? No estoy seguro de que exista una única correcta. Una aplicación R4 que muestra lo que hay en class prefiere la opción 2. Un informe que necesita contar todas las visitas virtuales, independientemente de la versión en que se escribieron, prefiere el significado de R6. Cada implementador tendrá que decidir para su propio caso de uso. Lo importante es elegir deliberadamente y hacer la elección visible para los clientes, en lugar de ocultarla dentro de un parámetro estándar.

Definimos class-r6 como un SearchParameter R4 propio en un pequeño paquete FHIR local. El canonical manager lo carga junto al núcleo de R4, el servidor lo sirve en /R4/SearchParameter/Encounter-class-r6, y el CapabilityStatement hace referencia a él:

{
  "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 búsqueda en sí se ejecuta como SQL sobre el R4 almacenado. FML solo convierte los resultados para /R6.

BodyStructure: un recurso, varias filas

BodyStructure es donde los dos modelos dejan de alinearse. En R6, includedStructure es una lista: un BodyStructure puede describir una lesión en el antebrazo, el brazo superior y el hombro. R4 tiene un único location y una única morphology.

Nuestra solución, que es diseño propio y no de HL7, consiste en almacenar un BodyStructure R6 como un recurso R4 por estructura, con ids abc, abc--2, abc--3. Una extensión gestionada por el servidor las vincula: la primera fila lista las demás, y cada fila adicional apunta de vuelta a la primera.

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

El resto se deriva de esto:

  • Una escritura reescribe el conjunto completo de filas en una transacción, desde cualquier versión.
  • Los valores exclusivos de R6 van en extensiones entre versiones, como morfologías adicionales, lateralidad y puntos de referencia anatómicos.
  • Los ids de fila permanecen estables, y el includedStructure.id de R6 de cada estructura es su número de fila.
  • R6 solo muestra abc. GET /R6/BodyStructure/abc--2 devuelve 404.

Existe otra forma de dividir, que posiblemente sea la más clara. Numerar cada estructura, abc--1, abc--2, abc--3, y no almacenar ningún recurso R4 llamado abc. Una lectura de GET /R4/BodyStructure/abc devolvería entonces un OperationOutcome que lista los ids reales:

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

Los compromisos van en ambas direcciones:

abc, abc--2, abc--3 (lo que construimos)abc--1, abc--2, abc--3 + OperationOutcome
Leer abc a través de R4Devuelve la primera estructura, que parece el conjunto completoIndica claramente que está dividido y dónde están las partes
Referencias R4 existentes a abcSiguen funcionando y apuntan a la primera estructuraApuntan a algo que no se puede leer como BodyStructure
Eliminar la primera estructuraLa siguiente pasa a ocupar abc, por lo que las referencias deben reescribirseNada se mueve: cada estructura conserva su id
Clientes R4Pueden ignorar la división hasta que sea relevanteDeben entender el OperationOutcome desde el primer día

Optamos por mantener abc como un recurso real, principalmente para que las referencias y los clientes R4 existentes sigan funcionando. Al igual que con la semántica de búsqueda, no creo que haya una única respuesta correcta. Cada sistema tendrá que decidir en función de sus propios clientes.

Referencias a un recurso dividido

Dividir un recurso le da dos identidades: los clientes R4 ven filas, los clientes R6 ven un único recurso. Todo recurso que apunte a un BodyStructure ahora debe traducirse entre ambos.

En R6, un Observation apunta a un BodyStructure mediante bodyStructure. R4 no tiene ese elemento, por lo que la extensión estándar http://hl7.org/fhir/StructureDefinition/bodySite lleva el enlace. Como un BodyStructure de R6 corresponde a varias filas R4, un único enlace no es suficiente: en R4, BodyStructure/abc es solo el antebrazo. Por eso el mapa R6 → R4 resuelve la referencia y escribe un enlace por fila:

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.
Una referencia R6 se convierte en un enlace R4 por fila, y viceversa.

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

En el camino de vuelta, Observation_4to6 elimina el sufijo --N y los duplicados se fusionan en la referencia única que el cliente envió. Tres reglas del servidor mantienen la coherencia:

  • Conservar. Si un cliente R4 redujo el enlace a una sola fila, una actualización R6 mantiene esa restricción, porque R6 no puede expresarla.
  • Reapuntar. Si se elimina la primera estructura, la siguiente pasa a ocupar la primera fila, y cada Observation que apuntaba a la fila antigua se reescribe en la misma transacción.
  • Rechazar. Todo lo que no supere el viaje R6 → R4 → R6, como un bodyStructure proporcionado como concepto, se rechaza con 422.

Qué significa esto para los clientes R4 existentes

Un único conjunto de datos no significa que los clientes R4 no se vean afectados. Probablemente necesitarán algunos cambios:

  • Una lectura puede ser parcial. GET /R4/BodyStructure/abc devuelve solo la primera estructura. El resto están en filas que el cliente solo encuentra a través de la extensión row-group.
  • Los datos R6 llegan como extensiones. Las clases adicionales de Encounter y statusReason de Observation, por ejemplo, son extensiones entre versiones. Un cliente que las ignora pierde datos, y uno que las elimina en una actualización depende de que el servidor las conserve (el nuestro lo hace).
  • La búsqueda encuentra menos. R4 class no coincide con una clase que solo existe en R6. El cliente tiene que conocer class-r6.
  • Los datos cambian sin que ellos escriban. Las referencias apuntan a filas que pueden moverse, y el servidor reescribe los Observations cuando lo hacen. Las versiones cambian sin que el cliente escriba, por lo que los conflictos de If-Match se vuelven más probables.

Conclusiones

1. Es un ejercicio de modelado más que técnico

La parte técnica funcionó. Un motor FML ejecutó los mapas generados R4 → R6 sin modificación, y los mapas escritos a mano cubrieron renombrados, traducción de códigos, listas plegadas en un único valor con extensiones, y un recurso dividido en varios. FML es un motor de mapeo adecuado para esta arquitectura. El canonical manager, fhirschema y PostgreSQL hicieron su trabajo.

Las preguntas difíciles fueron todas sobre el modelo, y la especificación no las responderá por usted. R4 y R6 ofrecen formas diferentes para los mismos datos: una clase o una lista de clases, una estructura corporal o varias, un código de localización corporal o una referencia. Decidir cómo una forma se convierte en la otra depende de lo que significan los datos en su sistema, por lo que el modelado de negocio propio acaba incorporándose a los mapeos. Ninguna versión de la especificación tomará esas decisiones por usted.

2. El modelado debe poder modificarse

Esas decisiones cambiarán. El trabajo entre versiones de HL7 publicará convenciones, sus clientes pondrán objeciones, y algunas elecciones resultarán erróneas. Por eso las reglas deben ser visibles y reversibles, no enterradas en el código: mapas en lugar de conversiones escritas a mano, parámetros de búsqueda que son definiciones publicadas, y una lista documentada de qué va dónde.

Hay un inconveniente: como el almacén es R4, cada decisión de modelado es también un formato de almacenamiento. Durante el hackathon cambiamos la URL de la extensión de clase de Encounter una vez, y los Encounters ya almacenados bajo la URL anterior perdieron silenciosamente su clase adicional en la vista R6. Revertir una decisión implica migrar los datos escritos bajo ella, así que planifíquelo desde el principio.

3. Los clientes R4 no podrán eludir los cambios

Es tentador pensar que las extensiones entre versiones son datos adicionales que un cliente R4 puede ignorar sin problemas. No es así. Lo que acaba en ellas suele ser el núcleo del registro: el enlace de un Observation a las estructuras corporales que describe, la segunda clase de un Encounter, las filas de un BodyStructure más allá de la primera. Un cliente que las ignora no solo pierde detalle. Obtiene una respuesta incorrecta, y sus búsquedas se comportan de forma diferente a las de R6. Los clientes R4 existentes necesitarán al menos algunos cambios para leer los datos correctamente.

Pruébelo

Compartir este artículo
Comments
Comments
Sign in
Loading comments...
Subscribe to our blog

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