El Bundle Que Vuelve con un 400
Montas un bundle de transacción —un Patient, un Encounter, tres Observations—, lo envías por POST a la raíz del servidor y recibes un único rechazo poco explicativo. Como la transacción es atómica, una sola entrada defectuosa tumba el conjunto, y el error rara vez señala cuál. Depurar consiste, por tanto, en inspeccionar el bundle que realmente enviaste en una forma donde las relaciones se vean.
Esta guía recorre las cuatro cosas que rompen bundles de transacción reales: el direccionamiento con fullUrl y urn:uuid, la semántica de entry.request, las referencias que no resuelven y las cuestiones de orden y dependencias que preocupan mucho más de lo que deberían. Todo se apoya en las páginas de API RESTful y Bundle de la especificación FHIR R4. Para inspeccionar los recursos y el grafo de referencias en local mientras trabajas, usa el Explorador de Bundles FHIR; para la anatomía del propio Bundle, empieza por Qué es un Bundle FHIR.
fullUrl y el Truco de urn:uuid
El problema que debe resolver un bundle de transacción es circular. Quieres crear un Patient y un Observation en una sola llamada atómica, y el Observation tiene que apuntar al Patient, pero el Patient todavía no tiene id asignado por el servidor porque no existe hasta que el servidor lo cree. No puedes escribir Patient/123 porque no sabes cuál será el 123.
La respuesta de FHIR es Bundle.entry.fullUrl combinado con el esquema urn:uuid:. Generas un UUID en el cliente, pones urn:uuid:8f2c1e4a-… en el fullUrl de la entrada del Patient y haces que el Observation referencie esa misma cadena urn:uuid: en subject.reference. El servidor, al crear el Patient y asignarle un id real, reescribe en el bundle todos los enlaces que coincidan. La especificación R4 es explícita sobre el alcance de esa reescritura: el servidor "SHALL replace all matching links in the bundle, whether they are found in the resource ids, resource references, elements of type uri, url, oid, uuid", incluidos los atributos href y src de la narrativa.
De ahí se siguen tres consecuencias, y cada una es un fallo habitual.
- El identificador temporal debe coincidir exactamente. La cadena de la referencia y la del
fullUrlse comparan como texto. Un UUID que difiere en mayúsculas, un espacio de más, un prefijourn:uuid:presente en un lado y ausente en el otro: cualquiera de esas cosas rompe el enlace y el servidor ve una referencia a algo que no existe. - En un POST se ignora el
iddel propio recurso. La especificación indica que al procesar una creación el full URL se trata como el id del recurso en el origen y se ignora; el id lo genera el servidor. Poner un esperanzado"id": "patient-1"en una entrada POST y referenciarPatient/patient-1en otra parte no funciona: ese id no es el que usará el servidor. fullUrldebe ser único dentro del bundle. La invariante de Bundle que lo regula (bdl-5) exige que fullUrl sea único en el bundle, o bien que las entradas que compartan fullUrl difieran enmeta.versionId, salvo en bundles de historial. Copiar y pegar una entrada y olvidar cambiar su UUID deja dos entradas reclamando la misma dirección y la resolución de referencias queda ambigua.
Una regla relacionada que pilla a quien construye bundles por programa: un recurso solo puede aparecer una vez en una transacción, por identidad. Dos entradas que resolverían al mismo recurso no siguen la lógica de "gana la última": son un error.
Cuándo Usar urn:uuid y Cuándo No
Usa urn:uuid para los recursos que el bundle está creando. Para los que ya existen en el servidor destino, usa la URL real, absoluta o relativa —Patient/123 o http://servidor/fhir/Patient/123—, porque no hay nada que reescribir e inventar un UUID para un recurso existente solo crea un enlace sin destino. Mezclar ambos en un mismo bundle es normal y correcto: Observations nuevos que apuntan a un Patient existente por Patient/123 y a un Encounter recién creado por urn:uuid.
entry.request: La Parte Que No Es el Recurso
Cada entrada de una transacción o un batch lleva un elemento request que indica qué hacer con el recurso. Según la invariante bdl-3, entry.request es obligatorio en bundles de tipo batch, transaction e history y está prohibido en el resto, y por eso pegar un searchset o una collection en un POST de transacción falla de inmediato, y por eso un bundle de tipo document no puede enviarse como transacción sin reconstruirlo.
Dos campos hacen el trabajo. request.method es uno de POST, PUT, PATCH, DELETE, GET o HEAD. request.url es la URL relativa a la que apunta la operación. En su emparejamiento se concentran los errores:
- POST —
urles solo el tipo de recurso:Patient. NoPatient/123. Es una creación; el id lo asigna el servidor. - PUT —
urles tipo más id:Patient/123. Es una actualización o creación en una dirección conocida. Si quieres conservar un id concreto, PUT es la vía; POST no lo respetará. - DELETE —
urles tipo más id, y la entrada no lleva recurso. - GET / HEAD — una lectura dentro de la transacción, también sin cuerpo de recurso.
Una entrada POST o PUT sin resource, o una entrada DELETE que sí lo lleva, está malformada. También lo está un request.url escrito como URL absoluta contra otro servidor, o que empieza por barra: request.url es relativa a la base del servidor.
Operaciones Condicionales Dentro de request
request transporta además las cabeceras condicionales, y son la causa habitual de que aparezca el clásico bug de "pacientes duplicados" en producción. ifNoneExist convierte un POST en una creación condicional: el servidor ejecuta la búsqueda indicada y, si encuentra exactamente una coincidencia, ignora el POST y devuelve 200 OK en lugar de crear una segunda copia; varias coincidencias producen un 412. ifMatch, ifNoneMatch e ifModifiedSince protegen de forma equivalente actualizaciones y lecturas. Si tu integración crea un Patient nuevo en cada ejecución porque los datos demográficos vuelven a llegar, la solución suele ser ifNoneExist sobre un identificador de negocio estable.

Por Qué una Referencia No Resuelve
"La referencia no resuelve" es el fallo más frecuente en transacciones y tiene un número reducido de causas. Recorrerlas en orden localiza casi todas.
- Ninguna entrada tiene ese fullUrl. La referencia apunta a un
urn:uuidque ninguna entrada reclama, normalmente por un UUID generado dos veces o por una entrada perdida al montar el bundle. - Forma de direccionamiento equivocada. La entrada se direcciona por
urn:uuidpero se referencia comoPatient/8f2c…, o al revés. Dentro de un bundle son cadenas distintas y solo una coincide. - El destino es realmente externo. La referencia apunta a un recurso del servidor que el bundle no contiene. Es legítimo, y que resuelva depende por completo de que ese recurso exista en el servidor destino, algo que ninguna inspección local puede confirmar.
- Se usó una referencia lógica donde se esperaba una literal. FHIR permite
Reference.identifieren lugar deReference.referencecuando no hay URL literal disponible. Es válido, pero no es un enlace que el bundle pueda resolver, y los servidores difieren en si lo aceptan, lo resuelven o lo rechazan. Cómo funcionan las referencias FHIR trata la distinción en detalle. - Confusión con recursos contained. Una referencia de fragmento
#p1apunta a un recurso contenido dentro del mismo recurso, no a una entrada del bundle. Sacar un recurso contenido a su propia entrada sin actualizar la referencia deja un fragmento apuntando a la nada.
Aquí es donde la inspección local se paga sola. El Explorador de Bundles FHIR construye el grafo de referencias entre los recursos del fichero, comparando cada referencia por Tipo/id, por id desnudo, por fullUrl y por el Tipo/id final de un fullUrl: las cuatro formas con que las entradas de un bundle se direccionan realmente entre sí. Lo que no encaja con ninguna se lista como no resuelto en lugar de ocultarse, lo que convierte un "el servidor ha dicho que no" en una lista concreta de cadenas de referencia sin destino. La resolución es estrictamente local: nunca se descarga nada por red, porque un bundle son datos de paciente y traer un recurso referenciado desde un servidor externo los expondría.
Orden y Dependencias: Menos Importantes de lo Que Parece
El mito más persistente sobre los bundles de transacción es que las entradas deben ordenarse para que las dependencias vayan primero: Patient antes que Encounter, Encounter antes que Observation. No es así. La especificación R4 define la secuencia de procesamiento por método, no por orden documental: el servidor procesa primero las interacciones DELETE, después las POST, después las PUT o PATCH, después las GET o HEAD y, por último, resuelve las referencias condicionales.
Como todas las creaciones ocurren en una misma fase y la reescritura de enlaces se aplica a todo el bundle, una entrada Observation situada antes que la de su Patient funciona sin problema. Donde el orden sí cuenta es entre fases: un GET posterior ve el resultado de las creaciones previas, y un DELETE se ejecuta antes que todo lo demás, lo que sorprende si borras y recreas el mismo recurso en un solo bundle.
Los problemas reales de dependencia en transacciones casi nunca son de secuencia. Son de identidad —la referencia y el fullUrl no son la misma cadena— o de referencias condicionales que no pueden resolverse porque la búsqueda que codifican encuentra cero o muchos recursos. Si te descubres reordenando entradas para arreglar un fallo, probablemente el fallo esté en otro sitio.
¿Transaction o Batch?
Una decisión más que conviene revisar al depurar. Una transacción es atómica: el servidor acepta todas las acciones y devuelve 200 OK, o rechaza todo con una respuesta de clase 400 o 500. Un batch procesa las entradas de forma independiente e informa del resultado de cada una. Si tus entradas son realmente independientes —las observaciones inconexas de una noche, por ejemplo—, un batch te dice exactamente cuáles tres de doscientas han fallado en vez de tumbar las doscientas. Si las entradas son un único evento clínico que debe aterrizar junto, la transacción es lo correcto y la atomicidad es precisamente el objetivo. Depurar un batch es bastante más cómodo, así que pasar a batch de forma temporal es una maniobra de diagnóstico legítima aunque la respuesta en producción sea transaction.
Un Orden de Depuración Que Funciona
Cuando un bundle de transacción es rechazado, trabaja de la estructura hacia fuera:
- Confirma que
Bundle.typeestransactiony que cada entrada tiene unrequestcon método y URL relativa. - Revisa los emparejamientos método/url: POST al tipo desnudo, PUT a tipo/id, sin cuerpo de recurso en DELETE.
- Lista todos los
fullUrly comprueba que son únicos; lista todas las cadenas de referencia y contrástalas con ese conjunto. Lo que no encaje es externo a propósito o es un bug. - Comprueba que las entradas POST no dependen de su propio
idy que los ids referenciados no son ids que esperabas que el servidor respetara. - Solo entonces mira la conformidad —elementos obligatorios, bindings, perfiles— con un validador como nuestro Validador de Recursos FHIR. Un bundle estructuralmente sano todavía puede fallar por cardinalidad; véase errores de cardinalidad y slicing del validador FHIR.
Mantén separadas las dos preocupaciones: un explorador de bundles responde a "qué hay aquí dentro y cómo se enlaza", un validador responde a "es conforme cada recurso". El Explorador de Bundles es deliberadamente de solo lectura y no valida, y justamente por eso es seguro apuntarlo a un bundle de producción en mitad de una incidencia. Admite entradas de hasta 25 MB y 20.000 recursos, informa explícitamente de cuántos se descartaron si se alcanza un límite y hace todo en tu navegador: no se transmite ningún dato de paciente mientras depuras. Si tu origen es una exportación masiva en lugar de una transacción construida a mano, abrir una exportación NDJSON de FHIR cubre la otra forma que lee el mismo utillaje.