RecomendadoEn desarrollo

HealthPack

Proyecto personal — Ingeniero en solitario · September 2026 — Present

  • Java 25
  • Spring Boot 4
  • REST
  • GraphQL
  • gRPC
  • WebSocket
  • Apache Kafka
  • Kafka Streams
  • RabbitMQ
  • PostgreSQL
  • pgvector
  • Redis
  • MinIO / S3
  • Keycloak (OIDC)
  • Spring AI
  • Claude (Anthropic API)
  • HAPI FHIR R4
  • HL7 v2
  • Docker
  • Kubernetes
  • OpenTelemetry
  • Stripe

Una plataforma de coordinación hospitalaria que estoy construyendo: historias clínicas, citas, consultas, resultados de laboratorio, recetas, documentos y un asistente de IA, diseñados como servicios independientes que se mantienen sincronizados anunciando lo que ha ocurrido en lugar de preguntarse unos a otros. El diseño está completo en los 12 diagramas de abajo y la construcción está en marcha: los servicios de pacientes, citas y gateway ya funcionan, y el servicio clínico es el siguiente.

El problema de fondo

La información de un paciente vive a trozos. Sus citas están en un sistema, sus análisis en otro, sus recetas en un tercero y sus facturas en un cuarto. Ninguno se comunica bien con los demás.

Las consecuencias son corrientes y constantes. Un médico receta un medicamento sin ver que choca con otro que recetó un colega el mes pasado. Un resultado crítico se queda sin leer durante horas. Un paciente llama a recepción para preguntar algo que ya está respondido en un documento que nadie encuentra. El personal vuelve a teclear los mismos datos en cuatro pantallas distintas.

Nada de esto es un problema tecnológico en el sentido emocionante. Es un problema de coordinación. La información existe; simplemente nunca llega adonde hace falta, cuando hace falta.

La solución

HealthPack está diseñado como quince servicios pequeños e independientes en lugar de uno grande. Cada uno se encarga de una sola tarea y la hace bien: uno gestiona las historias clínicas, otro las citas, otro los resultados de laboratorio, otro las recetas, otro los documentos y otro el asistente de IA.

Se mantienen sincronizados difundiendo lo que pasa. Cuando un resultado de laboratorio queda validado, el servicio de laboratorio lo anuncia una sola vez, en lab.result.finalized. Todo lo que tiene interés en ello —la cronología médica del paciente, el sistema de alertas del médico, el circuito de facturación, el índice de búsqueda del asistente de IA— recibe el mismo anuncio por Kafka y reacciona por su cuenta. Nadie tiene que acordarse de avisar a nadie.

Esa es toda la idea: el sistema se cuenta a sí mismo lo que ha pasado, y lo que tiene que ocurrir ocurre solo. Kafka transporta los hechos —cosas que han pasado, reproducibles, conservadas— y RabbitMQ transporta el trabajo —genera este PDF, envía esta notificación—: un worker, un intento, reintentado si falla.

Las partes difíciles

Las cosas fallan, y el sistema tiene que sobrevivir. En un sistema de quince servicios independientes, que uno esté caído un rato es lo normal, no la excepción. El diseño de HealthPack mantiene el fallo acotado: si el servicio de notificaciones cae, las citas se siguen reservando; los recordatorios esperan en RabbitMQ y salen cuando se recupera. No se pierde nada.

Nada debería ocurrir dos veces. Una receta nunca debe emitirse dos veces porque se reintentó una petición de red. Cada generación de documento lleva una clave de idempotencia, comprobada antes de empezar cualquier trabajo, de modo que repetir el mismo comando produce el mismo resultado que ejecutarlo una vez.

Velocidad donde importa. El panel de un médico reúne información de cinco servicios distintos. Hecho de forma ingenua, son decenas de peticiones separadas y una página lenta. La capa GraphQL las agrupa en una sola llamada gRPC por servicio en lugar de una por fila de paciente, así que la página carga en un único viaje de ida y vuelta.

La privacidad es una restricción de diseño, no una funcionalidad. El consentimiento se comprueba antes de que el asistente de IA lea nada. Los datos personales se eliminan antes de que cualquier dato salga de los servidores del propio hospital. No son añadidos: la arquitectura los da por supuestos desde el primer diagrama.

Historias clínicas

Una identidad verificada única para cada paciente, para que todos los demás servicios hablen de la misma persona. Los campos sensibles se cifran uno a uno, no solo con un «la base de datos está cifrada».

Citas

Los pacientes reservan en línea y ven la disponibilidad real. Una restricción de exclusión en la base de datos hace que sea estructuralmente imposible guardar dos reservas del mismo médico en la misma franja, no solo algo que se comprueba con cuidado.

Registros clínicos

Los médicos registran visitas, diagnósticos y mediciones. Todo lleva fecha y hora, se vincula a la cronología del paciente y se codifica con CIE-10, SNOMED CT y LOINC, los estándares que de verdad usan los hospitales.

Resultados de laboratorio

Los resultados llegan en el formato de mensajería hospitalario estándar (HL7 v2) directamente desde los equipos de laboratorio. Un proceso de streaming vigila cada resultado en busca de valores peligrosos y avisa a un médico en segundos, saltándose el modo no molestar.

Recetas

Antes de emitir una receta, se comprueba contra todo lo que el paciente está tomando. Una interacción peligrosa bloquea la orden y explica exactamente por qué; si se fuerza, queda registrado para siempre con el motivo que da el médico.

Documentos

Informes de alta, informes de laboratorio, recetas y facturas se generan como PDF de calidad de archivo, firmados criptográficamente para que cualquier manipulación sea detectable. La generación ocurre en segundo plano: nadie espera delante de una pantalla de carga.

Asistente de IA

Pacientes y personal preguntan en lenguaje natural. El asistente responde solo a partir de los documentos reales de ese paciente, cita la fuente de cada afirmación y lo dice claramente cuando los documentos no contienen la respuesta.

Mensajería

Chat en tiempo real entre pacientes y profesionales sanitarios, y con el asistente de IA. Las respuestas llegan token a token en lugar de aparecer tras una larga pausa.

Notificaciones

Recordatorios de citas, avisos de resultados disponibles y valores críticos, enviados por push, correo o SMS según lo que haya elegido cada persona. Las alertas críticas siguen un camino aparte y más rápido que se salta las horas de silencio.

Facturación

Las facturas se generan automáticamente al cerrar una visita, con los pagos con tarjeta gestionados mediante Stripe y un outbox transaccional que mantiene ambos sincronizados.

Registro de auditoría

Cada acción de cualquier persona queda registrada en un log encadenado por hashes a prueba de manipulaciones. Alterar un registro pasado rompe la cadena y se detecta: la normativa lo exige, y la mayoría de los sistemas lo implementan mal.

La arquitectura, en diagramas

Doce diagramas, en el orden en que de verdad le explicaría el sistema a otro ingeniero: primero la visión completa, luego los datos que posee cada servicio, después lo que expone cada uno y, por último, seis flujos de petición trazados de principio a fin.

01

Arquitectura general — Flujo completo del sistema

Cada servicio, cada protocolo, cada broker, en una sola página.

Este es el diagrama que abriría primero. Los clientes solo hablan con edge-gateway, que valida cada JWT contra Keycloak antes de reenviar la petición: las llamadas REST van directas a un servicio de dominio, GraphQL va al BFF y una actualización a WebSocket va a chat-service. Nada de lo que hay detrás vuelve a comprobar la autenticación; el gateway es el único sitio que lo hace, y todo lo demás confía en los claims que reenvía.

Las flechas dobles gruesas son gRPC, y se concentran alrededor de terminology-service por una razón: se llama en casi todas las escrituras clínicas (validar un código CIE-10, comprobar una interacción entre fármacos, resolver un código LOINC), así que es el único sitio donde un protocolo lento se notaría en todas partes. Las flechas punteadas son Kafka y RabbitMQ, y el diagrama es en realidad un argumento para mantenerlos separados: Kafka reparte un evento entre cinco consumidores distintos (solo lab.result.finalized alimenta lo clínico, la facturación, el asistente de IA y las suscripciones GraphQL), mientras que RabbitMQ lleva órdenes a exactamente un worker: un PDF que generar, una notificación que enviar.

Leído de arriba abajo, las capas cuentan su propia historia: un borde que solo valida y enruta, una capa de composición que existe únicamente para ahorrarle viajes al frontend, siete servicios de dominio que poseen cada uno una base de datos Postgres y nada más, un servicio gRPC interno detrás de todos ellos y tres workers asíncronos a los que ningún cliente llama nunca directamente.

02

Diagrama de clases — Identidad y núcleo clínico

Patient, Practitioner, Consent, Appointment, Encounter: las entidades de las que cuelga todo lo demás.

Todo en HealthPack acaba apuntando a un Patient, así que este es el modelo que diseñé primero. La relación que merece una pausa es Appointment "1" --> "0..1" Encounter : produces: una cita es una reserva, un encuentro es la visita real, y son registros separados a propósito. Un paciente puede reservar una cita y no presentarse; alguien sin cita puede generar un encuentro sin ninguna cita detrás. Fusionar ambos haría imposible representar con limpieza las ausencias y las visitas sin cita.

Consent cuelga directamente de Patient en lugar de esconderse en una tabla de ajustes, porque el asistente de IA lo comprueba en el camino crítico de cada pregunta que responde: isActiveFor(ConsentScope) se llama antes de recuperar un solo documento, no después. Observation y Condition pertenecen ambas a Encounter por composición (el rombo relleno), lo que significa que no pueden sobrevivir a la visita en la que se registraron: una medición siempre tiene una fecha y un contexto clínico, nunca flota suelta.

03

Diagrama de clases — Órdenes, documentos y facturación

Cómo una orden de laboratorio, una receta y una factura acaban produciendo el mismo tipo de artefacto.

Las tres flechas discontinuas que convergen en DocumentJob son el sentido de este diagrama: un LabOrder, una Prescription y una Invoice son conceptos clínicos o financieros sin relación entre sí, pero todos terminan su vida igual: generando un PDF. Modelar esa convergencia de forma explícita permitió escribir document-service una sola vez, de forma genérica, en lugar de tres veces con tres circuitos de PDF sutilmente distintos.

DocumentJob lleva una idempotencyKey por un motivo concreto: un consumidor de RabbitMQ puede recibir el mismo mensaje dos veces (ese es el trato que ofrece RabbitMQ: entrega al menos una vez, nunca como mucho una), y la receta de un médico nunca debe generarse, firmarse y guardarse dos veces por una llamada de red reintentada. El contador attempts del propio job y el método markFailed(String) existen para que una generación atascada aparezca como dato, no como un hueco silencioso.

04

Diagrama de clases — IA, chat, notificaciones y auditoría

Cómo se estructuran de verdad una respuesta RAG, un mensaje de chat y un registro de auditoría.

RagQuery guarda más que la respuesta: conserva inputTokens y cacheReadTokens junto a la List~Citation~, porque una respuesta RAG sin fuente no es un dato que valga la pena mostrar a un médico, y un recuento de tokens sin la cifra de lectura de caché no dice nada sobre si el prompt caching funciona realmente en producción. Cada Citation lleva un rango de páginas, no solo un id de documento, para que el asistente pueda señalar el párrafo exacto que cita.

AuditRecord es la única clase de este diagrama que se referencia a sí misma: AuditRecord --> AuditRecord : prevHash chain. El hash de cada registro se calcula sobre sus propios campos más el hash del registro anterior, así que alterar cualquier entrada pasada, aunque sea un solo campo, rompe todos los hashes posteriores. verifyChain(AuditRecord prev) es lo que llama de verdad un auditor, y o confirma la cadena o te dice exactamente dónde se rompió.

05

Métodos de servicio — Superficie de API síncrona

Todos los métodos REST y gRPC que exponen los ocho servicios dirigidos por peticiones, en una sola vista.

Esto es menos un diagrama de clases que un mapa de quién llama a quién de forma síncrona. Las tres flechas de dependencia ..> hacia TerminologyService desde ClinicalService, PharmacyService y el código próximo a SchedulingService confirman la decisión de diseño que insinuaba el diagrama general: las consultas de terminología son la única llamada síncrona verdaderamente transversal, y por eso ese servicio es solo gRPC y no REST.

BffGraphQlResolvers merece una lectura atenta: batchLoadPatients(Set~UUID~) : CompletableFuture~Map~ es la función batch de DataLoader, y su firma es toda la razón por la que la capa GraphQL evita las consultas N+1: los resolvers piden un paciente cada vez, DataLoader reúne los IDs dentro de un mismo tick del event loop y este único método los obtiene todos en una sola llamada gRPC. onVitalsUpdated y onLabResultReady devuelven Flux, no un valor: son suscripciones GraphQL alimentadas por consumidores de Kafka por debajo, no por polling.

06

Métodos de servicio — Servicios asíncronos y en tiempo real

Los servicios a los que nada llama directamente: solo reaccionan a una cola o a un stream.

Fíjate en lo que falta en todos los métodos: no hay ningún createX ni getX expuesto a un cliente. DocumentService.onRenderCommand, NotificationService.onNotifyCommand y onCriticalValue, AiRagService.onDocumentTextExtracted: cada punto de entrada empieza por on, porque cada punto de entrada es un manejador de mensajes, no un endpoint. Un cliente puede consultar el estado de un job a través de una pequeña fachada REST, pero nunca puede pedir a estos servicios que hagan algo directamente; solo puede pedírselo a una cola.

ChatService ..> AiRagService : gRPC server-stream es la única llamada de aspecto síncrono en un diagrama por lo demás asíncrono, y está ahí porque una respuesta de chat tiene que sentirse instantánea: pasarla por Kafka añadiría un salto de cola que el usuario notaría mientras espera a que aparezcan los tokens. AiRagService.evaluateGoldenSet() es un método sin llamador a propósito: lo invoca la CI, no el sistema en marcha, para puntuar la calidad de las respuestas contra un conjunto fijo de pares pregunta/respuesta antes de publicar un cambio.

07

Secuencia — Lectura Patient 360

Una carga del panel, cinco servicios, una llamada gRPC a cada uno.

Esta es la secuencia que justifica todo el emparejamiento GraphQL/gRPC. Un profesional abre un panel que necesita datos demográficos, encuentros y resultados de laboratorio: tres servicios y, potencialmente, decenas de pacientes en pantalla a la vez. El paso 10, «DataLoader collects all IDs in one event-loop tick», es el momento clave: en lugar del patrón ingenuo N+1 (una llamada por paciente y por campo), la petición de cada resolver por un ID se pone en cola durante un único tick y luego se envía como un solo lote.

El bloque par que sigue lanza tres llamadas agrupadas en paralelo —gRPC batchGet a patient-service, gRPC batchGetEncounters a clinical-service y una llamada REST a lab-service— y el propio patient-service muestra la misma disciplina por dentro: primero un MGET contra Redis y una consulta PostgreSQL solo para lo que no estaba en caché. La nota del final lo resume sin rodeos: una llamada gRPC por servicio, no una por fila de paciente, da igual cuántos pacientes haya en pantalla.

08

Secuencia — Reserva de cita → Recordatorio push

Una doble reserva que no puede ocurrir y un recordatorio que no puede perderse.

Aquí se eliminan dos modos de fallo por diseño, no se detectan a posteriori. El SET slot-hold NX EX 120 de Redis impide que dos personas compitan por la misma franja en los segundos que se tarda en rellenar un formulario, y el INSERT de Postgres que hay debajo está protegido por una restricción de exclusión sobre (practitioner_id, time_range): aunque dos peticiones pasaran de algún modo la comprobación de Redis, la propia base de datos rechaza la segunda escritura. La corrección ante la concurrencia está en el único sitio que puede garantizarla de verdad: la base de datos, no un código de aplicación que espera haber comprobado a tiempo.

La segunda mitad es el patrón outbox en acción: la inserción de la cita y la del outbox ocurren en la misma transacción, así que «la reserva salió bien pero nadie se enteró» no es un estado en el que el sistema pueda caer; un poller que corre independientemente de la petición recoge la fila del outbox y publica appointment.booked cuando está listo. A partir de ahí el recordatorio toma el camino lento a propósito: el mecanismo TTL más dead-letter exchange de RabbitMQ implementa la espera de 24 horas, y si el proveedor de push falla, el mensaje hace nack con reencolado y retrocede de forma exponencial antes de acabar en una dead-letter queue en lugar de desaparecer.

09

Secuencia — Valor crítico de laboratorio → Alerta en tiempo real

De un mensaje HL7 en la red a un móvil vibrando, en un solo salto de Kafka Streams.

Este es el flujo del que va realmente todo el proyecto: es el que describe su primera frase. Un equipo de laboratorio habla HL7 v2, un formato de 1989 que lab-service analiza con HAPI HL7v2 y valida contra LOINC antes de guardar nada. La decisión interesante llega justo después: en lugar de que el propio lab-service decida qué es crítico, publica el hecho —lab.result.finalized— y una topología de Kafka Streams aparte, particionada por ID de paciente con una deduplicación en ventana deslizante de 15 minutos, hace de juez. La detección está desacoplada del registro a propósito, para que la lógica de alertas pueda cambiar sin tocar el servicio dueño de los datos.

Cuando un valor entra en zona crítica, una única publicación en lab.critical-value se reparte en paralelo a tres consumidores: notification-service la envía a una cola prioritaria que se salta por completo las horas de silencio, chat-service empuja un frame STOMP directamente a cualquier sesión abierta del médico y la suscripción GraphQL actualiza su panel en vivo. Tres caminos de entrega distintos, un solo evento, para que lo vea sea cual sea la forma en que el médico esté mirando el sistema en ese momento.

10

Secuencia — Generación de PDF → Ingesta RAG

Un PDF de receta firmado se convierte en algo que el asistente de IA puede citar.

La comprobación de idempotencia del principio es lo primero que ocurre, antes de cualquier trabajo real: document-service busca la idempotencyKey entrante en Postgres y, si ya se generó, confirma el mensaje y no hace nada más. Esa única consulta es lo que hace seguro que RabbitMQ vuelva a entregar el mismo comando de generación sin producir nunca dos PDF firmados para la misma receta.

Pasado ese control, el circuito son en realidad dos circuitos seguidos. El primero genera, aplica un perfil de archivo PDF/A, firma con la implementación PAdES de BouncyCastle y guarda el objeto en MinIO por su sha256. El segundo solo empieza cuando el primero se ha confirmado por completo: PDFBox vuelve a extraer el texto del PDF que acaba de escribir, publica document.text-extracted, y ese único evento es lo que hace que el asistente de chat pueda recuperar el documento: troceado, convertido en embeddings con Voyage AI e insertado en pgvector con el ID del paciente como metadato, que es lo que después permite a una consulta RAG filtrar los documentos de un solo paciente y de nadie más.

11

Secuencia — Chat con el asistente de IA

El consentimiento se comprueba antes de recuperar un solo documento, y luego los tokens se transmiten según llegan.

El orden de las operaciones aquí es todo el argumento de privacidad hecho concreto: ai-service llama a hasConsent(patientId, AI_ASSIST) contra identity-service antes de convertir la pregunta en embedding, antes de consultar pgvector, antes de leer una sola palabra del historial del paciente. Un consentimiento revocado devuelve PERMISSION_DENIED en ese punto y la conversación se detiene ahí: el asistente nunca llega lo bastante lejos como para tener algo que ocultar.

Una vez superado el consentimiento, la recuperación se limita a ese paciente (topK 8, filtrado por patientId) y el contexto recuperado se anonimiza de datos sanitarios personales antes de construir el prompt: el modelo nunca ve más datos identificativos de los que exige la respuesta. La nota cache_control en el paso de construcción del prompt importa tanto para el coste como para la latencia: el prompt de sistema y el corpus de guías clínicas son estables en todas las preguntas que hace el equipo que atiende a este paciente, así que el prompt caching de Anthropic convierte la mayor parte de ese contexto en una lectura de caché en lugar de un cobro nuevo, y cacheReadTokens se registra precisamente para verificar que eso ocurre de verdad en lugar de suponerlo. A partir de ahí el bucle es un relevo directo: Claude transmite eventos content_block_delta, ai-service reenvía cada uno por un server-stream gRPC, chat-service lo convierte en un frame STOMP y el navegador lo pinta de forma incremental, así que la respuesta aparece como escribe una persona en lugar de llegar en bloque tras una espera.

12

Secuencia — Receta con comprobación de interacciones

Una caché de dos niveles delante de la única comprobación que no se puede saltar.

La cascada de caché Caffeine y luego Redis existe porque validateCode se llama en casi todas las escrituras clínicas de todo el sistema, y una consulta de terminología que fuera a Postgres cada vez haría más lenta cada una de esas escrituras sin motivo: los conjuntos de códigos apenas cambian, así que una caché L1 local al proceso respaldada por una caché L2 compartida es la solución obvia, y cada fallo rellena ambos niveles a la vuelta para que la siguiente petición, en cualquier punto del clúster, se beneficie.

La comprobación de interacciones es donde el diagrama se gana su sitio: un resultado contraindicado no hace fallar la petición, sino que devuelve 422 con el conflicto concreto, y el profesional puede forzarlo, pero solo aportando una justificación que se guarda junto a la receta con un indicador explícito de anulación. Nada se bloquea en silencio y nada se permite en silencio; cada receta discutida deja constancia de quién anuló qué y por qué. Las dos publicaciones finales —prescription.issued a Kafka y un comando de generación a RabbitMQ— son exactamente donde retoma el diagrama 4d.

A fondo: la parte técnica

El software sanitario es un problema de sistemas distribuidos realmente difícil disfrazado de algo aburrido. Tiene requisitos de consistencia estrictos (no puedes reservar dos veces un quirófano), requisitos de latencia estrictos (un valor crítico de potasio no sirve de nada con una hora de retraso), requisitos de cumplimiento estrictos (cada lectura de una historia clínica es auditable por ley), una superficie de integración tremendamente heterogénea (HL7 v2 de 1989 junto a FHIR REST) y datos sanitarios personales que hacen que cada decisión sobre el flujo de datos tenga consecuencias.

Lo elegí a propósito. Mi trabajo anterior —un portal Drupal/Next.js en Atos, una plataforma de reparto con Spring Boot en OpenTecc— estaba bien construido, pero su arquitectura era de una sola pieza. «Microservicios» y «Kafka» eran conceptos que entendía, no artefactos que pudiera enseñar. Este proyecto existe para que esa afirmación sea demostrable, y para construir algo que de verdad pueda ayudar a un hospital a funcionar de forma más segura, no solo para engordar una lista de tecnologías.

Por qué un monolito no funcionaría

Cuatro propiedades del dominio obligan a dividirlo:

1

Carga muy asimétrica

Las consultas de terminología se ejecutan en casi todas las escrituras clínicas. La facturación se ejecuta una vez por encuentro. En un monolito comparten pool de hilos y heap; un bucle intenso de terminología degrada la generación de facturas sin motivo.

2

Presupuestos de latencia muy asimétricos

Una alerta de valor crítico tiene un SLA de segundos. Generar un PDF puede tardar treinta segundos y a nadie le importa. Ponerlos en el mismo camino de petición significa que el presupuesto más estricto se impone en todas partes.

3

Dominios de fallo independientes

Que Stripe esté caído no debe impedir que un médico registre un encuentro. En un monolito, el ámbito transaccional compartido y los pools de conexiones compartidos hacen que ese aislamiento sea muy difícil de lograr de verdad.

4

Radio de impacto regulatorio

Los servicios que tocan datos sanitarios personales necesitan cifrado, control de consentimiento y auditoría. Los que no, no deberían pagar ese peaje. Un monolito convierte cada componente en sujeto a esas obligaciones por defecto.

Estrategia de protocolos

Tres transportes, cada uno con una justificación de una frase.

REST — Toda API externa y entre fronteras. Cacheable, depurable con curl, nativa del navegador y la opción por defecto correcta. FHIR R4 tiene forma de REST, así que la interoperabilidad sanitaria viene de serie.

GraphQL — Exactamente un servicio, el BFF. La vista patient-360 del frontend necesita datos de cinco servicios con una forma que decide el cliente. Hacerlo por REST supone cinco viajes de ida y vuelta o un endpoint de agregación a medida por pantalla: Spring for GraphQL, schema-first, con autorización a nivel de campo y límites de profundidad y complejidad de las consultas.

gRPC — Solo entre servicios internos, concentrado en terminology-service. Payloads de menos de un kilobyte en los que el envoltorio JSON es una parte importante del mensaje, llamados en casi todas las escrituras clínicas, con un esquema que realmente nunca cambia de forma. El emparejamiento DataLoader/gRPC del BFF es el diseño que señalaría primero: parece que los resolvers consultan campo a campo, pero DataLoader agrupa todos los IDs dentro de un tick del event loop y los reparte con una sola llamada gRPC por servicio; mira el diagrama Patient 360 de arriba para ver exactamente cómo funciona.