Go back

Curelios - Engineering Case Study (Backend)

Ahmed TariqJuly 25, 202616 min read
spring bootmodular-monolithhealthcarebackend

Overview

Curelios is a telehealth "second-opinion" platform: a patient describes a concern (optionally anonymously), gets an immediate AI-mediated triage response, and is then routed to a licensed doctor who reviews, corrects, and takes over the conversation, escalating to encrypted chat, a paid written report, or a live HD video consultation as needed.

The backend is a single, cohesive Spring Boot service that acts as the system of record and the coordination hub for three client surfaces:

  • Flutter iOS app (patients and doctors),

  • Next.js anonymous query web platform (no-account second opinions), and

  • CRM/admin surface (moderators).

It is deliberately a modular monolith: one deployable, clean package boundaries and a message broker for the parts that genuinely need decoupling (real-time delivery).

Context & Problem

The product had to satisfy several hard requirements simultaneously:

  1. Medical-grade confidentiality. Message content and sensitive identifiers must not be readable by the server or by anyone with database access alone.

  2. Two distinct user types with different data and privileges - patients and doctors - plus a moderator role, all sharing one identity/auth backbone.

  3. Anonymous access. A patient must be able to get a second opinion without creating an account or revealing identity, yet still receive an asynchronous doctor reply later.

  4. Real-time consultation. Low-latency chat and reliable HD audio/video, with delivery guarantees.

  5. Payments for reports/consultations, with the server - as the source of truth for transactions.

  6. GDPR obligations: encryption in transit and at rest, data minimisation, right to erasure, abuse protection, and auditability.

Architecture at a Glance

architecture-at-glance.png

Tech Stack & the Reasoning Behind It

Concern

choice

why this

Language / framework

Java 22 + Spring Boot 3.3.8

Mature ecosystem for security, JPA, WebSocket, AMQP, and payment SDKs in one place.

Persistence

Spring Data JPA / Hibernate + MySQL (RDS)

Rich relational model (users, appointments, records, transactions) with derived-query repositories; RDS gives managed backups for regulated data.

Identity model

JOINED-table inheritance

(User -> Doctor/Patient)

Shared identity/auth columns in one table, role-specific columns in child tables, normalised, no sparse nullable columns, clean polymorphic queries.

AuthN/Z

Spring Security (stateless) + JJWT(Java JSON Web Token)

Stateless JWT fits a mobile + web + horizontal-scale deployment (no server-side session store).

Rate limiting

Bucket4j + Caffeine

In-process token buckets (global + per-IP) with a bounded, self-expiring cache - abuse protection with no extra infra.

Real-time delivery

Spring WebSocket + Spring AMQP (RabbitMQ / LavinMQ)

WebSocket for the socket; AMQP for durable offline queues, retries, and dead-lettering - the guarantees a chat app needs on flaky mobile networks.

Media storage

AWS S3 + CloudFront signed URLs

Private objects, edge delivery, and short-lived signed access so no medical file is ever publicly reachable.

Payments

Stripe Java SDK

Payment Intents + server-side confirmation; battle-tested compliance surface.

AI triage

OpenAI (GPT-4o vision) via REST

Vision-capable model for text + photo triage, wrapped behind strict prompt guardrails and human review.

Crypto

BouncyCastle + JCA

Provider for CloudFront RSA signing and at-rest field encryption.

Email

Spring Mail + Thymeleaf

Server-rendered transactional HTML (OTP, receipts, alerts).

Push

OneSignal (incl. APNs VoIP)

Cross-platform push with VoIP-priority delivery for incoming calls.

API docs

SpringDoc OpenAPI (Swagger UI)

Self-documenting API for the mobile/web clients built in parallel.

Feature Deep-Dives

Identity, Roles, and the Inheritance Model

The identity backbone is an abstract User entity, extended by Doctor and Patient (a Moderator role also exists for the CRM). Shared columns - credentials, verification status, privacy flags, timestamps - live in the user table; role-specific data (a doctor's field/experience/gallery/work history, a patient's conditions/medications) lives in child tables.

Clients address users by an opaque, non-enumerable ID, and the internal primary key never appears in a payload or URL. Sensitive fields (password, ipAddress, fcmToken) are @JsonIgnore, and a configurable masking serializer can redact additional fields on the way out.

Privacy is modelled as first-class data. Each user has independent, granular privacy dials, a general privacy and notification flag. These are enforced server-side at the point of action, never merely hidden in the UI.

Doctor onboarding is a verification state machine: Patients skip the license stages. A doctor uploads their medical license, and only an approved account can practise on the platform.

Doctor verification lifecycle:

doctor-verification.png

Authentication & Session Security

Authentication is stateless JWT with a custom filter chain, hardened well beyond a "issue a token" setup.

JWT + custom filter: JWT Filter, a OncePerRequestFilter inserted before Spring's username/password filter) extracts the bearer token, validates it, loads the principal, and - importantly - also captures the client IP and a Device header into the authenticated principal. Requests without a Device header don't get an authenticated context, which underpins device-binding below.

The security config is fully stateless (SessionCreationPolicy.STATELESS), CSRF disabled (no cookies-as-auth), and CORS locked to the production origins (curelios.com, api.curelios.com).

Refresh-token rotation. Access JWTs are short-lived; long-lived sessions are backed by opaque refresh tokens persisted per user. An endpoint validates the token, mints a fresh JWT, and rotation replaces the stored token value, so a leaked token can be revoked, and reuse is observable.

Device binding + new-device alerts. On sign-in and on token refresh, the deviceId is compared against the incoming Device header. A mismatch triggers a "new device detected" email (with IP and time) and updates the bound device - giving users an account-takeover signal without adding friction.

Password security. Passwords are hashed with bcrypt. The pepper is held by the application, not the database, so a database dump alone doesn't expose crackable hashes at full strength.

Sign-in, stateless JWT, and refresh-token rotation with device binding:

sign-in-stateless-jwt.png

End-to-End Encrypted Messaging (Zero-Knowledge Server)

Direct patient<->doctor chat is end-to-end encrypted with the Signal protocol (libsignal on the Flutter client), and the backend is intentionally zero-knowledge for message content.

  • Each user publishes a serialized Signal pre-key bundle, stored in a PreKeys entity via uploadPreKeys. Peers fetch a recipient's bundle via fetchPreKeys to establish a session. The server is a key directory and relay, never a party to the session.

  • Messages traverse the server as ciphertext only. The server routes and persists/queues them but cannot read them. Per-conversation key rotation and key material live on-device (Flutter Secure Storage); the server never sees plaintext.

  • The message envelope carries a small type discriminator which lets the client drive control-plane behavior (including expiry/rotation signaling) over the same channel without leaking content.

The practical property: a compromise of the database or the application server yields ciphertext, not conversations.

Real-Time Delivery Layer (AMQP with Guarantees)

The 1:1 chat socket (RabbitMQSocketHandler) is the most involved piece of infrastructure, because reliable messaging on mobile is a distributed-systems problem in miniature. It layers RabbitMQ/LavinMQ under the WebSocket to provide delivery guarantees:

  • At-least-once delivery with application ACKs. Every message sent to a connected client is tracked as a PendingMessage keyed by a UUID messageId. The client must ACK by echoing that ID. If no ACK arrives within a 10-second window, the message is retried - up to 3 attempts.

  • Dead-letter handling. After retries are exhausted, the message is published to a dead-letter exchange rather than silently dropped, so undeliverable messages are captured for inspection/replay.

  • Durable offline queues. If the recipient isn't connected, the message is written to a durable per-user queue declared with a 24-hour TTL and a dead-letter exchange. On reconnect, the client's offline backlog is drained and redelivered with the same ACK tracking.

  • A topology laid out for future horizontal scale. The handler declares topic exchanges for live, ACK, and dead-letter traffic and a server-scoped queue bound to the live exchange - the foundation for cross-instance fan-out. In its current form it runs single-instance (the server-instance ID is fixed and the cross-server consumer is stubbed out); local WebSocket sessions live in-memory. So "scales horizontally" is a design intent with the scaffolding in place.

  • Privacy enforced at send time. Before a message is delivered, the recipient's chatPrivacy is checked; allowing the user to filter or block unwanted messages in their chat.

  • Notification fan-out. Delivery also triggers an asynchronous OneSignal push (respecting the recipient's notification preference) so offline users are still nudged.

This is the concrete implementation behind "LavinMQ-backed message queue for delivery and notification fan-out with retry and dead-letter handling."

Message delivery: ACK tracking, bounded retry, offline queue, and dead-lettering:

ack-tracking.png

Real-Time Consultation: Custom WebRTC Signaling + Room Chat

Live consultations run on WebRTC HD audio/video with a custom Spring Boot signaling server, plus a separate in-consult chat channel:

  • The WebRTC signaling server relays SDP offer/answer and ICE candidates between the two participants of a room and broadcasts presence. The handshake interceptor authorises the connection by confirming (via the JWT principal) that the user is a participant in an ACTIVE appointment before the socket is even established - you cannot join a call room you don't belong to.

  • A room-scoped text chat during a consultation is persisted to the appointment's message history; its handshake interceptor likewise verifies participation.

Call invitations are delivered as high-priority OneSignal pushes with APNs VoIP semantics, carrying the room and appointment IDs so the callee's device can ring and join directly. Media itself is peer-to-peer WebRTC; the server only ever brokers the initial signaling, keeping audio/video off the application servers.

WebRTC signaling over /signal - the server relays SDP/ICE; media stays peer-to-peer:

webRTC-signaling.png

Anonymous Second Opinions + AI Triage (Human-in-the-Loop)

The Next.js "anonymous query" flow lets someone get a second opinion with no account and no disclosed identity, while still enabling an asynchronous doctor reply. This is one of the more carefully designed subsystems.

Identity-free but resumable. A new anonymous chat is created with a random token. The raw token is the patient's access link and is never persisted - knowledge of the link is the capability. Chats auto-expire after specified time. An email address, if the patient wants reply notifications, is stored AES-encrypted, never in the clear.

AI triage with hard guardrails. The patient's first message (and an optional photo, sent to the vision model goes to OpenAI GPT-4o. The system prompt constrains the assistant to be a non-diagnostic triage assistant: acknowledge the concern empathetically, give general information, flag red-flag/emergency symptoms, state clearly that the reply is preliminary and a doctor will review it, and never diagnose, prescribe, or drift off-topic. This is the "non-prescriptive triage assistant with prompt-level guardrails" in practice.

Human-in-the-loop is enforced by data, not convention. The AI reply is saved with PENDING status . A doctor then reviews it and sets APPROVED / DISAPPROVED / REMOVED, or edits it. There's even a revision path that can send the doctor's notes back to the model to rewrite the patient-facing message in the doctor's voice. Nothing AI-authored reaches the patient as final until a licensed doctor has acted on it - the "hand off all clinical questions to licensed doctors" guarantee, made structural.

Confidentiality throughout. Anonymous message content and uploaded filenames are stored with field-level AES encryption (via a JPA attribute converter); uploaded images live in a private S3 prefix and are served only through short-lived CloudFront signed URLs. Chats can be forwarded to another connected doctor (e.g., for specialty routing), and new activity fans out to the assigned doctor via push, WebSocket, and email.

Compliance caveat worth being explicit about: the triage step sends symptom text and, optionally, a medical photo to a third-party model provider (OpenAI). For EU health data that is a processing activity in its own right - it needs a data-processing agreement with no-training/retention terms, a valid transfer mechanism, and an explicit lawful basis for special-category data before it runs on real patients. In this build it exercises the product flow; the contractual and legal wrapping around that sub-processor is roadmap work, not a solved problem.

Anonymous intake -> AI triage -> mandatory human review before anything reaches the patient:

anonymous-intake.png

Medical records are patient-owned and shared under explicit, revocable consent, with all binary content kept private.

  • Secure media pipeline. Files never transit the application server as bytes. To upload, a client requests a presigned S3 PUT URL and uploads directly to S3 under their credentials. To read, the service returns CloudFront signed URLs (RSA-signed canned policy) - objects are private at the origin and only reachable through a freshly minted, expiring, signed link. (On-device OCR redaction with Google ML Kit happens client-side before upload, so the backend only ever receives already-redacted documents.)

  • Consent-gated sharing. A patient shares a specific record with a specific doctor. Sharing is gated by the doctor's record privacy policy and the relevant connection/appointment relationship is verified before a share is allowed. Sharing is fully revocable by either party.

  • Doctor annotations. A doctor with access can attach doctor notes to a shared record; the patient is notified in-app (WebSocket), by push, and by email.

  • Lifecycle hygiene. Editing a record diffs old vs. new file sets and deletes orphaned S3 objects; deleting a record purges its files from S3. No dangling private media is left behind - which also matters for erasure obligations.

Appointments & Scheduling

Appointments span two modes and a full lifecycle state machine.

  • Two booking modes. REQUEST (patient requests; doctor accepts/rejects) and SCHEDULE (slot-based booking against the doctor's availability).

  • Availability model. Each doctor defines a work schedule per day-of-week with a slot duration. Booking validates that the requested time falls within the doctor's schedule and on a valid slot boundary, and that the slot isn't already taken.

  • Booking concurrency. Before booking, the service runs an existence check against active bookings for the slot, and Appointment carries an optimistic locking on row updates. Honest limitation: optimistic locking protects concurrent edits to a given appointment row - it does not, on its own, make "no other booking exists for this slot" atomic. Closing the last race fully wants a unique database constraint on (doctor, date, time).

  • Lifecycle state machine. REQUEST -> ACTIVE (accept) / REJECTED / CANCELLED; scheduled bookings start as BOOKED; an ACTIVE consult transitions to FINISHED. Each transition is authorised against the acting user's role and ownership of the appointment.

  • Rich aggregate. An appointment bundles the attached medical records, in-consult messages, written reports (free or paid), and mutual reviews (doctor rates patient and vice-versa, each stored once).

Appointment lifecycle - two entry modes converge on a single consult state machine:

appointment-lifecycle.png

Payments (Stripe) - Server as Source of Truth

Payments use Stripe Payment Intents, designed so the server, not the client, decides whether money moved.

  • Intent creation + persistence. The server creates a Payment Intent (automatic payment methods enabled) and immediately persists a transaction in pending, capturing the intent ID, amount, currency, purpose (e.g., REPORT), the payer/payee, and the raw intent JSON.

  • Server-confirmed charges. Confirmation re-retrieves the intent from Stripe and only on a SUCCEEDED status does the server flip the transaction to SUCCESS, mark the report paid, record the doctor's earning, and email a receipt. A client claiming success is never trusted on its own.

  • Idempotency. Requesting a payment intent for a report that already has a transaction returns the existing intent instead of creating a duplicate - so retries and double-taps don't double-charge.

  • Earnings & payouts. Doctor earnings and withdrawals are modelled as their own state machines with branched, payout accounts and Stripe payout parameters wired in.

Payment - the server, not the client, decides that money moved (idempotent + confirm-on-read):

payment-server.png

GDPR & Data Protection (Cross-Cutting)

GDPR alignment is implemented as a set of concrete mechanisms rather than a policy document:

  • In transit: HTTPS-only origins, proxy-aware forwarded-header handling, credentialed CORS restricted to the production domains.

  • At rest: A subset of sensitive fields (anonymous email, anonymous message content, uploaded filenames) is encrypted with a JPA Attribute Converter that transparently encrypts on write and decrypts on read. Passwords are bcrypt + server-side pepper; secrets like passwords/IPs/push tokens are excluded from serialization. Two honest qualifications: this is field-level encryption over selected columns (structured medical-record metadata relies on database/disk-level protection, not this converter), and the converter's current cipher mode should be upgraded to authenticated encryption with a managed key before production. Managed full-database encryption (RDS) covers the storage layer underneath.

  • Private media everywhere: S3 objects are private; access is exclusively via short-lived CloudFront signed URLs (with a signed-cookie signer also available for session-scoped access).

  • Abuse protection / rate limiting: A high-precedence Rate Limiting Filter enforces a global cap (requests/minute) and a per-IP cap using Bucket4j token buckets in a bounded, self-expiring Caffeine cache. Client IP resolution is spoof-resistant - X-Forwarded-For/X-Real-IP are only trusted when the direct peer is a known internal/proxy CIDR - and rejected requests return 429 with Retry-After and X-RateLimit-* headers. To be precise: the limits today are global and per-IP (a uniform per-IP budget), which is the foundation for IP-based abuse detection; genuinely per-endpoint budgets (tighter caps on auth and payment routes) are a straightforward extension, not something the current filter does yet.

  • Data minimisation for anonymous users: no account, hashed access tokens (raw token never stored), encrypted optional email, and automatic chat expiry.

  • Field masking: a configuration-driven Masking Serializer can redact any named field type-correctly in API responses without touching call sites.

  • Auditability (partial): creation/modification/last-login/last-used timestamps across entities and new-device email alerts give a basic trail; infrastructure-level access logging (e.g. CloudWatch, where deployed) sits underneath.

Supporting Services

  • Transactional email (Spring Mail + Thymeleaf HTML templates): email-verification OTPs, new-device alerts, payment receipts, password reset, account-deletion OTP, anonymous-chat reply alerts, and newsletter subscribe/unsubscribe (tokenised).

  • Push notifications (OneSignal): chat, record-share, doctor-note, appointment, and VoIP call pushes - all dispatched asynchronously.

  • Doctor discovery: list all, filter by specialty/field, keyword search, look-up by username/private ID, profile galleries, work-experience CRUD, and privacy-settings updates.

  • Connections graph: follow/following/pending/blocked relationships that feed the privacy gates on chat, records, and appointments.

  • CRM / moderation surface: a moderator-role sign-in and user-management (paged user listing) for admin oversight.

  • Content feed: a Reddit integration (app-token OAuth) that pulls curated subreddit content for in-app resources.

  • Version gate: An endpoint that mobile clients use for force-update / compatibility gating.

Cross-Cutting Engineering

  • Resilience: application-level ACK/retry/DLQ for messaging; AMQP publisher confirms and template/listener retry configured; graceful shutdown; optimistic locking on contended writes.

  • Performance: direct-to-S3 uploads/downloads (the app never proxies file bytes), edge delivery via CloudFront, tuned Hikari/Tomcat pools, and asynchronous side-effects so third-party latency never blocks a response.

  • Security posture: stateless auth, refresh-token rotation + revocation, device binding, spoof-resistant rate limiting, consent-gated data access enforced in the service layer, and E2E encryption keeping the server out of message content.

  • Maintainability: consistent layered packages, DTO boundary so entities never leak directly, enum-driven state machines that make valid transitions explicit, and OpenAPI docs generated for the parallel client teams.

Core relationships (simplified):

core-relationships.png

Engineering Trade-offs & Hardening Roadmap

A candid section, because owning something end-to-end also means knowing exactly where the edges are.The good news: the architecture was built so each item below is an isolated, low-risk change rather than a rewrite.

Must-fix before any real patient data (security & correctness):

  • Externalise and rotate all secrets. The JWT signing key, the field-encryption key, the CloudFront private key, and third-party API keys currently live in source, and deployment config carries DB/AWS/Stripe/OpenAI credentials. All of it moves to a managed secret store (AWS Secrets Manager / Parameter Store / KMS) and gets rotated. Anything that was ever committed is treated as compromised. This is the single highest-priority item.

  • Upgrade the at-rest cipher. Replace the AES/ECB converter with AES-GCM (authenticated, per-record IV) keyed from KMS, and widen coverage where a field-level guarantee is actually needed.

  • Enforce refresh-token expiry. Re-enable the time-based expiry check so tokens expire by expiryDate, not only on explicit revocation.

  • Make slot booking atomic. Add a unique DB constraint so the last booking race is closed at the database, not just in application logic.

  • Stop logging PII. Remove identifier logging from the auth paths and replace ad-hoc stdout with structured, PII-safe logging.

  • Tighten the authorization matrix. Narrow broadly-permitted routes, enforce method-level authorisation per role, and lock CORS/permit-lists per client surface.

Reliability & correctness:

  • Formalise Stripe webhooks. Complement confirm-on-read with signed webhooks (signature verification + event idempotency keys) so payment state converges even if a client never calls back.

  • Complete the multi-instance socket tier. Give each instance a real ID and finish the cross-server live-message consumer so the WebSocket layer can actually run more than one node.

  • Tests & migrations. Add a real test suite (currently effectively just a context-load test) and adopt schema migrations (Flyway/Liquibase) instead of Hibernate auto-DDL.

  • Observability. Standardise metrics/tracing and a tamper-evident, application-level audit log of record access.

Compliance & process (out-of-code, but required):

  • A DPIA and Records of Processing for the health-data processing.

  • Data-processing agreements with every sub-processor - most importantly the AI provider, since triage sends symptoms and photos off-platform - plus valid transfer mechanisms and an explicit Article 9 lawful basis.

  • A real erasure + retention policy reconciling GDPR Article 17 with German medical-record retention duties.

  • Independent sign-off: a security review/pen-test and a privacy professional's review before any compliance claim is made publicly or contractually.

Grouping it this way is the point: the "must-fix" list is short and mechanical, and none of it requires re-architecting.

What Shipped

A privacy-by-design, second-opinion telehealth backend that a single engineer designed and built end-to-end - powering a Flutter app, a Next.js anonymous platform, and a CRM. In closed beta it validated the full clinical flow: identity-free intake -> AI triage -> doctor review -> end-to-end encrypted chat ->HD video consultation

-> paid report -> securely shared medical records, across both patient and doctor roles.

Demonstrated capabilities: distributed messaging with delivery guarantees (AMQP ACK/retry/DLQ), real-time media signaling (custom WebRTC server), applied cryptography (E2E Signal integration, field-level encryption, signed-URL media access), payment integration with server-authoritative state, and a defence-in-depth security posture (rotating JWT sessions, device binding, spoof-resistant rate limiting, consent-gated data access) - designed around EU data-protection constraints from the outset.

You've reached the end. Thank you for reading.