RecommendedWork in Progress

HealthPack

Personal Project — Solo Engineer · 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

A hospital coordination platform I'm currently architecting: patient records, scheduling, clinical encounters, lab results, prescriptions, documents and an AI assistant, designed as independent services that stay in sync by announcing what happened rather than checking in on each other. The design is complete across all 12 diagrams below; no code has been written yet.

The Core Problem

A patient's information lives in pieces. Their appointments are in one system, their test results in another, their prescriptions in a third, their bills in a fourth. None of them talk to each other properly.

The consequences are ordinary and constant. A doctor prescribes a medication without seeing that it conflicts with something another doctor prescribed last month. A critical test result sits unread for hours. A patient calls the front desk to ask a question that's already answered in a document nobody can find. Staff retype the same information into four different screens.

None of this is a technology problem in the exciting sense. It's a coordination problem. The information exists — it just never arrives where it's needed, when it's needed.

The Solution

HealthPack is designed as fifteen small independent services instead of one large one. Each one owns a single job and does it well: one handles patient records, one handles appointments, one handles lab results, one handles prescriptions, one handles documents, one handles the AI assistant.

They stay in sync by broadcasting to each other. When a lab result is finalized, the lab service announces it once, on lab.result.finalized. Everything that cares — the patient's medical timeline, the doctor's alert system, the billing pipeline, the AI assistant's search index — hears the same announcement over Kafka and reacts independently. Nobody has to remember to notify anybody.

That's the whole idea: the system tells itself what happened, and the right things happen automatically. Kafka carries the facts — things that happened, replayable, kept — and RabbitMQ carries the work — render this PDF, send this notification, one worker, one attempt, retried on failure.

The Hard Parts

Things fail, and the system has to survive it. In a system of fifteen independent services, one being temporarily down is normal, not exceptional. HealthPack's design keeps a failure contained: if the notification service goes down, appointments still get booked — the reminders queue up in RabbitMQ and go out when it recovers. Nothing is lost.

Nothing should happen twice. A prescription must never be issued twice because a network request was retried. Every document render carries an idempotency key, checked before any work starts, so replaying the same command produces the same result as running it once.

Speed where it matters. A doctor's dashboard pulls information from five different services. Done naively that's dozens of separate requests and a slow page. The GraphQL layer batches them into one gRPC call per service instead of one per patient row, so the page loads in a single round trip.

Privacy is a design constraint, not a feature. Consent is checked before the AI assistant reads anything. Personal details are stripped before any data leaves the hospital's own servers. These aren't add-ons — the architecture assumes them from the first diagram — the design below is complete; none of it is built yet.

Patient records

A single verified identity for each patient, so every other service is talking about the same person. Sensitive fields are encrypted individually, not just "the database is encrypted."

Appointments

Patients book online and see real availability. A database exclusion constraint makes double-booking the same doctor for the same slot structurally impossible to save, not just carefully checked for.

Clinical records

Doctors record visits, diagnoses and measurements. Everything is timestamped, attached to the patient's timeline, and coded against ICD-10, SNOMED CT and LOINC — the standards hospitals actually use.

Lab results

Results arrive in the standard hospital messaging format (HL7 v2) straight from lab machines. A streaming job watches every result for dangerous values and alerts a doctor within seconds, bypassing do-not-disturb settings.

Prescriptions

Before a prescription can be issued it's checked against everything else the patient is currently taking. A dangerous interaction blocks the order and shows exactly why; an override is recorded permanently with the doctor's stated reason.

Documents

Discharge summaries, lab reports, prescriptions and invoices generate as archival-quality PDFs, cryptographically signed so tampering is detectable. Generation happens in the background — nobody waits on a loading screen.

AI assistant

Patients and staff ask questions in plain language. The assistant answers only from that patient's actual documents, cites its source for every claim, and says so plainly when the documents don't contain an answer.

Messaging

Real-time chat between patients and clinicians, and with the AI assistant. Answers stream in token by token instead of arriving after a long pause.

Notifications

Appointment reminders, results-ready alerts and critical values, delivered by push, email or SMS depending on what each person has chosen. Critical alerts take a separate, faster path that bypasses quiet hours.

Billing

Invoices generate automatically when a visit is completed, with card payments handled through Stripe and a transactional outbox keeping the two in sync.

Audit trail

Every action anyone takes is recorded in a tamper-evident, hash-chained log. Altering a past record breaks the chain and is detectable — regulations require this, and most systems implement it badly.

Architecture, Diagrammed

Twelve diagrams, in the order I'd actually explain the system to another engineer: the whole picture first, then the data each service owns, then what each service exposes, then six request flows traced end to end.

01

Master Architecture — Full System Flow

Every service, every protocol, every broker, on one page.

This is the diagram I'd open first. Clients only ever talk to edge-gateway, which validates every JWT against Keycloak before forwarding a request — REST calls go straight to a domain service, GraphQL goes to the BFF, and a WebSocket upgrade goes to chat-service. Nothing downstream re-checks authentication; the gateway is the one place that does, and everything else trusts the claims it forwards.

The thick double arrows are gRPC, and they cluster around terminology-service for a reason: it's called on nearly every clinical write (validating an ICD-10 code, checking a drug interaction, resolving a LOINC code), so it's the one place a slow protocol would be felt everywhere. The dotted arrows are Kafka and RabbitMQ, and the diagram is really an argument for keeping them separate: Kafka fans one event out to five different consumers (lab.result.finalized alone feeds clinical, billing, the AI assistant and the GraphQL subscriptions), while RabbitMQ carries commands to exactly one worker — a PDF to render, a notification to send.

Read top to bottom and the layers tell their own story: an edge that only ever validates and routes, a composition layer that exists purely to save the frontend round trips, seven domain services that each own one Postgres database and nothing else's, one internal gRPC service sitting behind all of them, and three async workers that no client ever calls directly.

02

Class Diagram — Identity & Clinical Core

Patient, Practitioner, Consent, Appointment, Encounter — the entities everything else hangs off.

Everything in HealthPack eventually points back to a Patient, so this is the model I designed first. The one relationship worth pausing on is Appointment "1" --> "0..1" Encounter : produces — an appointment is a booking, an encounter is the actual visit, and they're deliberately separate records. A patient can book an appointment and never show up; a walk-in can produce an encounter with no appointment behind it. Collapsing the two would make no-shows and walk-ins impossible to represent cleanly.

Consent sits directly off Patient rather than buried in a settings table, because the AI assistant checks it on the hot path of every question it answers — isActiveFor(ConsentScope) is called before a single document is retrieved, not after. Observation and Condition are both owned by Encounter through composition (the filled diamond), meaning they can't outlive the visit they were recorded in — a measurement always has a timestamp and a clinical context, never floats free.

03

Class Diagram — Orders, Documents & Billing

How a lab order, a prescription and an invoice all end up producing the same kind of artifact.

The three dashed arrows converging on DocumentJob are the point of this diagram: a LabOrder, a Prescription and an Invoice are completely unrelated clinical or financial concepts, but they all end their life the same way — triggering a PDF. Modelling that convergence explicitly meant document-service could be written once, generically, instead of three times with three subtly different PDF pipelines.

DocumentJob carries an idempotencyKey for a concrete reason: a RabbitMQ consumer can receive the same message twice (that's the deal RabbitMQ makes — at-least-once delivery, never at-most-once), and a doctor's prescription must never be rendered, signed and stored twice because of a retried network call. The job's own attempts counter and markFailed(String) method exist so a stuck render surfaces as data, not a silent gap.

04

Class Diagram — AI, Chat, Notification & Audit

How a RAG answer, a chat message and an audit record are actually structured.

RagQuery stores more than the answer — it keeps inputTokens and cacheReadTokens alongside the List~Citation~, because a RAG answer with no source is not a fact worth showing a clinician, and a token count with no cache-read figure tells you nothing about whether prompt caching is actually working in production. Every Citation carries a page range, not just a document id, so the assistant can point at the exact paragraph it's quoting.

AuditRecord is the one class in this diagram that references itself: AuditRecord --> AuditRecord : prevHash chain. Each record's hash is computed over its own fields plus the previous record's hash, so altering any past entry — even one field — breaks every hash after it. verifyChain(AuditRecord prev) is what an auditor actually calls, and it either confirms the chain or tells you exactly where it broke.

05

Service Methods — Synchronous API Surface

Every REST and gRPC method the eight request-driven services expose, in one view.

This is less a class diagram than a map of who calls whom synchronously. The three ..> dependency arrows into TerminologyService from ClinicalService, PharmacyService and SchedulingService-adjacent code confirm the design decision the master diagram implied: terminology lookups are the one truly cross-cutting synchronous call, which is exactly why that service is gRPC-only rather than REST.

BffGraphQlResolvers is worth reading closely: batchLoadPatients(Set~UUID~) : CompletableFuture~Map~ is the DataLoader batch function, and its signature is the whole reason the GraphQL layer avoids N+1 queries — resolvers ask for one patient at a time, DataLoader collects the IDs within a single event-loop tick, and this one method fetches all of them in a single gRPC call. onVitalsUpdated and onLabResultReady return Flux, not a value — they're GraphQL subscriptions, fed by Kafka consumers underneath, not by polling.

06

Service Methods — Async & Realtime Services

The services nothing calls directly — they only react to a queue or a stream.

Notice what's missing from every method here: there's no createX or getX exposed to a client. DocumentService.onRenderCommand, NotificationService.onNotifyCommand and onCriticalValue, AiRagService.onDocumentTextExtracted — every entry point starts with on, because every entry point is a message handler, not an endpoint. A client asks for a job's status through a small REST facade, but it can never ask these services to do something directly; it can only ask a queue to.

ChatService ..> AiRagService : gRPC server-stream is the one synchronous-looking call in an otherwise async diagram, and it's there because a chat reply has to feel instant — routing it through Kafka would add a queue hop the user would actually notice while waiting for tokens to appear. AiRagService.evaluateGoldenSet() is a method with no caller shown here on purpose: it's invoked by CI, not by the running system, to score answer quality against a fixed set of question/answer pairs before a change ships.

07

Sequence — Patient 360 Read

One dashboard load, five services, one gRPC call each.

This is the sequence that justifies the entire GraphQL/gRPC pairing. A practitioner opens a dashboard that needs patient demographics, encounters and lab results — three services, potentially dozens of patients on screen at once. Step 10, "DataLoader collects all IDs in one event-loop tick," is the moment that matters: instead of the naive N+1 pattern (one call per patient per field), every resolver's request for an ID is queued for a single tick, then flushed as one batch.

The par block that follows fires three batched calls concurrently — gRPC batchGet to patient-service, gRPC batchGetEncounters to clinical-service, and a REST call to lab-service — and patient-service itself shows the same discipline internally: an MGET against Redis first, a PostgreSQL query only for whatever wasn't cached. The note at the bottom states the payoff plainly: one gRPC call per service, not one per patient row, regardless of how many patients are on screen.

08

Sequence — Appointment Booking → Reminder Push

A double-booking that can't happen, and a reminder that can't get lost.

Two failure modes get designed out here, not caught after the fact. The Redis SET slot-hold NX EX 120 stops two people racing to book the same slot in the seconds it takes to fill out a form, and the Postgres INSERT underneath it is protected by a database exclusion constraint on (practitioner_id, time_range) — even if two requests somehow both pass the Redis check, the database itself refuses the second write. Concurrency correctness sits in the one place that can actually guarantee it: the database, not application code hoping it checked in time.

The second half is the outbox pattern in action: the appointment insert and the outbox insert happen in the same database transaction, so "the booking succeeded but nobody found out" isn't a state the system can get into — a poller running independently of the request picks up the outbox row and publishes appointment.booked whenever it's ready. From there the reminder takes the slow path on purpose: RabbitMQ's TTL-plus-dead-letter-exchange mechanism implements the 24-hour delay, and if the push provider fails, the message nacks with requeue and backs off exponentially before landing in a dead-letter queue rather than disappearing.

09

Sequence — Critical Lab Value → Realtime Alert

From an HL7 message on the wire to a phone buzzing, in one Kafka Streams hop.

This is the flow the whole project is really about — it's the one described in the project's opening line. A lab machine speaks HL7 v2, a format from 1989 that lab-service parses with HAPI HL7v2 and validates against LOINC before persisting anything. The interesting decision happens right after: rather than have lab-service itself decide what's critical, it publishes the fact — lab.result.finalized — and a separate Kafka Streams topology, keyed by patient ID with a 15-minute sliding-window dedupe, does the judging. Detection is decoupled from recording on purpose, so the alerting logic can change without touching the service that owns the data.

When a value crosses into critical, one publish to lab.critical-value fans out to three consumers in parallel: notification-service routes it to a priority queue that bypasses quiet hours entirely, chat-service pushes a STOMP frame straight to any open session the doctor has, and the GraphQL subscription updates their dashboard live. Three different delivery paths, one event, so however the doctor happens to be looking at the system in that moment, they see it.

10

Sequence — PDF Generation → RAG Ingestion

A signed prescription PDF becomes something the AI assistant can cite from.

The idempotency check at the very top is the first thing that happens, before any actual work: document-service looks up the incoming idempotencyKey in Postgres and, if it's already been rendered, acknowledges the message and does nothing else. That single lookup is what makes it safe for RabbitMQ to redeliver the same render command without ever producing two signed PDFs for the same prescription.

Past that guard, the pipeline is genuinely two pipelines back to back. The first renders, applies a PDF/A archival profile, signs with BouncyCastle's PAdES implementation, and stores the object in MinIO by its sha256. The second starts only once the first has fully committed: PDFBox extracts the text back out of the PDF it just wrote, publishes document.text-extracted, and that one event is what makes the document retrievable by the chat assistant at all — chunked, embedded through Voyage AI, and upserted into pgvector with the patient ID attached as metadata, which is what later lets a RAG query filter to one patient's documents and no one else's.

11

Sequence — AI Assistant Chat

Consent checked before a single document is retrieved, then tokens streamed as they arrive.

The order of operations here is the entire privacy argument made concrete: ai-service calls hasConsent(patientId, AI_ASSIST) against identity-service before it embeds the question, before it queries pgvector, before it reads a single word of the patient's record. A revoked consent returns PERMISSION_DENIED at that point and the conversation stops there — the assistant never gets far enough to have anything to redact.

Once consent clears, retrieval is scoped to that one patient (topK 8, filtered by patientId) and the retrieved context is redacted for PHI before it's built into the prompt — the model never sees more identifying detail than the answer requires. The cache_control note on the prompt-building step matters for cost as much as latency: the system prompt and guideline corpus are stable across every question this patient's care team asks, so Anthropic's prompt caching turns most of that context into a cache read instead of a fresh charge, and cacheReadTokens gets logged specifically to verify that's actually happening rather than assumed. From there the loop is a straight relay — Claude streams content_block_delta events, ai-service forwards each one over a gRPC server-stream, chat-service turns it into a STOMP frame, and the browser renders it incrementally, so an answer appears the way a person types rather than arriving as a block after a wait.

12

Sequence — Prescription with Drug Interaction Check

A two-tier cache in front of the one check that can't be skipped.

The Caffeine-then-Redis cache waterfall exists because validateCode is called on nearly every clinical write across the whole system, and a terminology lookup that hits Postgres every time would make every one of those writes slower for no reason — the code sets barely change, so an L1 process-local cache backed by an L2 shared cache is the obvious fix, and each miss populates both layers on the way back up so the next request anywhere in the cluster benefits.

The interaction check itself is where the diagram earns its place: a contraindicated result doesn't fail the request, it returns 422 with the specific conflict, and the practitioner can override it — but only by supplying a justification that gets persisted alongside the prescription with an explicit override flag. Nothing is silently blocked and nothing is silently allowed; every contested prescription leaves a record of who overrode what and why. The final two publishes at the bottom — prescription.issued to Kafka, a render command to RabbitMQ — are exactly where diagram 4d picks up.

Technical Deep-Dive

Healthcare software is a genuinely hard distributed systems problem wearing a boring costume. It has hard consistency requirements (you cannot double-book an operating room), hard latency requirements (a critical potassium value is worthless an hour late), hard compliance requirements (every read of a patient record is auditable by law), a wildly heterogeneous integration surface (HL7 v2 from 1989 sitting next to FHIR REST), and PHI that makes every data-flow decision consequential.

I picked it deliberately. My prior work — a Drupal/Next.js portal at Atos, a Spring Boot delivery platform at OpenTecc — was well-built but architecturally singular. I had "microservices" and "Kafka" as concepts I understood, not artifacts I could point to. This project exists to make that claim demonstrable, and to build something that could genuinely help a hospital run more safely, not just to pad a stack list.

Why a Monolith Wouldn't Work

Four properties of the domain force the split:

1

Wildly asymmetric load

Terminology lookups run on nearly every clinical write. Billing runs once per encounter. In a monolith they share a thread pool and a heap; a terminology hot loop degrades invoice generation for no reason.

2

Wildly asymmetric latency budgets

A critical-value alert has a seconds-level SLA. A PDF render can take thirty seconds and nobody cares. Putting them in the same request path means the strictest budget wins everywhere.

3

Independent failure domains

Stripe being down must not prevent a doctor from recording an encounter. In a monolith, shared transaction scope and shared connection pools make that isolation very hard to actually achieve.

4

Regulatory blast radius

Services touching PHI need encryption, consent gates and audit. Services that don't, shouldn't pay that tax. A monolith makes every component PHI-scoped by default.

Protocol Strategy

Three transports, each with a one-sentence justification.

RESTEvery external and cross-boundary API. Cacheable, debuggable with curl, browser-native, and the right default. FHIR R4 is itself REST-shaped, so the healthcare interop story comes free.

GraphQLExactly one service, the BFF. The frontend's patient-360 view needs data from five services with client-driven shape. Doing that over REST is either five round trips or a bespoke aggregation endpoint per screen — Spring for GraphQL, schema-first, with field-level authorization and query depth/complexity limits.

gRPCInternal service-to-service only, concentrated in terminology-service. Sub-kilobyte payloads where JSON framing is a large fraction of the message, called on nearly every clinical write, with a schema that genuinely never changes shape. The DataLoader/gRPC pairing in the BFF is the design I'd point to first: resolvers look like they fetch per field, but DataLoader batches every ID within one event-loop tick and fans out with a single gRPC call per service — see the Patient 360 diagram above for exactly how that plays out.