!DOCTYPE html> How We Rebuilt a Healthcare SaaS Platform — Ramsud Technologies
Case Study  ·  Healthcare SaaS

How We Rebuilt a Healthcare
SaaS Platform Around
Real Clinical Workflows

We took a monolithic healthcare platform — one where business rules were buried in stored procedures, triage logic lived in UI event handlers, and every change request took weeks — and rebuilt it around the way the clinical teams actually worked.

Healthcare SaaS Platform Case Study

A system that passed financial audits on first submission, eliminated scheduling conflicts across NHS trusts, and lets each product team ship independently.

7
Bounded Contexts
Scheduling, Patient, Clinical,
Admission, Lab, Billing, Notification
Weekly
Scheduling Cadence
Up from monthly releases in the monolith
0
Cross-Team Deployments
Features ship without coordinating with other teams

The Domain Knowledge Was Already There.
We Just Had to Surface It.

The domain knowledge is already embedded — in the old code, in the people who built it, and in the users who've been running it for years. Our job wasn't to discover the domain from scratch. It was to extract what was implicit, challenge what was assumed, and make it all explicit in the new model.

3
Hospital Trusts
Consulted before a single line was written
1
Shared Domain Glossary
Eliminated cross-team miscommunication from day one
4 hrs
Clinical Staff Onboarding
Down from 3 days in the legacy system
Clinical domain discovery sessions

From Legacy to Living System

Three hospital trusts. Weeks of structured interviews. One domain glossary that every team signed off before a single line of code was written.

The Core Problem

We started by interviewing the people closest to the legacy system — the clinical coordinators who worked around its limitations every day, the developers who knew where the bodies were buried, and the administrators who had built workarounds into their daily routines. Those conversations surfaced domain concepts the old system had never modelled correctly — referral pathways treated as free-text fields, triage bands hardcoded as integers, waiting time targets buried in a config table nobody owned.

What Those Early Conversations Revealed

  • A receptionist said "slot" — it meant one thing at the front desk and something else entirely to a consultant
  • "Appointment" was not the same as "consultation," even though both words were used interchangeably
  • Emergency cases, routine visits, and follow-up checks moved through different booking rules in practice
  • As staff corrected one another's wording, the team built a shared picture of the scheduling process

"Referral pathway" turned out to mean a structured sequence of specialist visits required before a procedure could be approved. Understanding that single term revealed an entire workflow.

How We Extracted a Domain Model From
a System That Never Had One

The legacy system had years of business logic — but none of it was named, structured, or intentional. Referral rules lived in stored procedures. Triage logic was scattered across UI event handlers. Waiting time targets were magic numbers in a config file.

Technical Deep Dive — Extracting a domain model is detective work
1

Audit the Legacy Code

We read stored procedures, config tables, and UI event handlers not as code, but as clues. Every hardcoded value and undocumented condition was a business rule waiting to be named.

2

Interview the Power Users

The people who had worked around the system's limitations for years knew the real rules better than the code did. We asked: "What does the system get wrong?" Their answers shaped the new model.

3

Name What Was Implicit

Terms like "referral pathway" and "triage band" existed in people's heads and in spreadsheets, but never in the codebase. We made them first-class domain concepts.

4

Validate Against Reality

We took our emerging model back to clinical coordinators and asked: "Does this reflect how you actually work?" Every correction refined the model further.

5

Replace, Don't Replicate

The goal was never to rebuild the legacy system in DDD clothing. Where the old system had modelled things incorrectly, we modelled them correctly — even if it meant changing behaviour users had learned to work around.

We Made the Business Vocabulary
Non-Negotiable — Everywhere

When developers, clinicians, and administrators all use different words for the same thing, the software ends up reflecting none of them. We established a single shared vocabulary — drawn directly from the clinical domain — that every team member used in every conversation, document, and line of code.

If a ward nurse can read your method names and still not tell what the system does, you have built a scheduling engine — not a hospital system.

Shared clinical vocabulary in code

One Language, Everywhere

API endpoints, event names, database columns, method signatures — all using the clinical domain’s own terminology. New engineers navigated the codebase using clinical knowledge alone.

Before: Four Teams, Four Vocabularies

The clinical team said "appointment." Admin called it a "booking slot." The codebase had insert into sched_txn. QA called it a "calendar entry." Nobody was wrong — but the system reflected none of them accurately, and every handoff created friction.

After: One Vocabulary, Everywhere

We established a shared glossary from the clinical domain and made it non-negotiable across stand-ups, API contracts, database schemas, and test cases. Patient, Referral, Appointment, Consultation, ReferralPathway, ConsultantSlot — the hospital's own words became the software's words.

How We Applied This

Every API endpoint, event name, database column, and method signature had to use terminology from the clinical domain glossary. We ran joint reviews where clinical leads and engineers read API documentation together — and that practice consistently surfaced misalignments before production. New engineers could navigate the codebase using clinical knowledge alone. Clinical staff could review system behaviour without needing a developer in the room.

Every Business Rule Got a Name.
Change Requests Stopped Being Scary.

Critical business rules were buried in raw numbers and unnamed conditions — invisible to anyone but the original developer. We gave every business rule a name and a home. When a client requested a custom pricing model, we composed existing named policies rather than rewriting logic. What would have taken two weeks shipped in a day.

14×
Faster Policy Changes
Custom pricing model: 2 weeks → 1 day
100+
Named Policy Classes
Every business rule visible, findable, testable
0
Hardcoded Magic Numbers
At launch — down from 200+ in the old system
Technical Deep Dive — The Hidden Rule Problem

The Problem

The appointment system needed to enforce urgent referrals seen within 2 weeks, routine within 18 weeks. The first version buried this in a raw conditional:

int maxWaitDays = (referral.urgency()
  .equals("URGENT")) ? 14 : 126;
if (daysSinceReferral > maxWaitDays)
  flagAsBreached();

What do 14 and 126 mean? A new developer has no idea this is a legally mandated NHS waiting time target.

What We Changed

We extracted this into a named WaitingTimeTargetPolicy class:

if (waitingTimeTargetPolicy
  .isBreached(referral))
    flagAsBreached();

Now the rule has a name. A clinical manager can find it, a compliance officer can validate it, and when the government updates the targets, there is exactly one place to change.

Named policy classes

The Model That Passed Audits Was
Never the Obvious One

The first version of any system reflects what's visible on the surface. The model that actually matters — the one that drives compliance, revenue, and clinical outcomes — only emerges through deep engagement with the business.

The Surface Model

The team first described the system as: patient calls → slot is booked → appointment is confirmed. It matched the receptionist's screen and the database tables. It felt complete.

What the Business Actually Cared About

After weeks of conversations with clinical leads, the real concern emerged: clinical pathway compliance — specifically, whether each patient was progressing through the correct sequence of specialist visits in the right order and within mandated timeframes. Booking a slot was just the visible surface of a much deeper workflow.

The Model That Mattered

Referral pathways, triage band escalations, inter-departmental handoffs, breach risk scores, and mandatory review checkpoints — the clinical governance layer beneath the simple "book and confirm" surface.

The first model reflects what the receptionist sees on screen. The deep model reflects what the clinical governance team is accountable for. Getting there means asking "what happens if this goes wrong?" — not just "what does this button do?"

How We Drove This in Our Transformation

When we began the platform rebuild, the old system modelled billing as a simple payment ledger. Through structured domain workshops with finance leads and compliance officers, we surfaced the deeper model: revenue recognition, subscription period accounting, and deferred revenue schedules. We built these as first-class domain concepts from the start — SubscriptionPeriod, RevenueSchedule, RecognitionEvent. The result was a billing engine that passed financial audits on first submission and gave clients real-time revenue visibility the old system could never provide.

We Kept Business Logic in One Place.
Everything Else Became Flexible.

Clinical rules, compliance policies, and pathway logic are completely isolated from databases, APIs, and frameworks. When a major client needed the same logic exposed via both a web API and a message queue, the change took one afternoon — because the business layer didn't move.

Presentation Layer
REST controllers, GraphQL resolvers, patient-facing portal. Translates user actions into application commands. No business logic.
Application Layer
Orchestrates use cases: BookAppointment, EscalateTriageBand, CancelReferral. Coordinates domain objects but holds no clinical rules itself.
Domain Layer
The protected core. Clinical rules, pathway policies, triage logic, waiting time targets. Patient, Referral, ReferralPathway, WaitingTimeTargetPolicy. Zero framework imports.
Infrastructure Layer
PostgreSQL, Redis, NHS Spine API, Kafka, email/SMS. Implements interfaces defined by the domain. Never the other way around.

The real test of a well-layered hospital system is whether the domain stays so clean that you could swap the database or the web framework without changing a single clinical rule. When that's true, the architecture is doing its job.

How We Structured It

In the new architecture, our domain layer is completely framework-free — no Spring annotations, no JPA imports, no HTTP references. Pure domain logic, expressed in the language of the business. When a major client required the same business logic exposed via both a REST API and an async message queue, the change was isolated entirely to the presentation and infrastructure layers. The domain didn't move. That's the architecture working exactly as intended.

We Didn't Over-Engineer Everything.
We Knew Where Complexity Lived.

Not every part of the system needed the same investment. Knowing the difference before we started was one of the most valuable calls we made.

Where We Kept It Simple

  • A staff rota display board with no business rules
  • An internal notice board for ward announcements
  • A simple visitor sign-in log
  • A one-page form for collecting patient feedback after discharge

These parts had no real business rules, so we built them quickly and without ceremony.

Where Simplicity Would Have Failed

  • Triage band rules that differ by specialty, urgency, and patient history
  • Waiting time targets that change with government policy updates
  • Referral pathways spanning multiple departments with sequential approvals
  • Integration with GP systems, NHS Spine, pharmacy, and radiology

The risk we saw most often wasn't choosing the wrong approach for a simple problem — it was underestimating a complex one at the start.

We Gave One Object the Authority.
Concurrency Problems Disappeared.

In the legacy system, multiple receptionists could simultaneously book the same consultant slot — because no single part of the system was responsible for enforcing that rule. We designated a single owner for every consistency rule. The result: peak booking loads across multi-site hospital trusts, zero scheduling conflicts.

We still use optimistic locking — but on one aggregate root, not scattered across five tables. The model decides where the lock belongs.

Technical Deep Dive

The Double-Booking Problem

Receptionist A books Patient X into Dr. Ahmed's 10am slot. Receptionist B books Patient Y into the same slot moments later. Both see it as available. Both succeed. Now Dr. Ahmed has two patients at 10am. Root cause: individual slot records were locked, but the invariant "a consultant slot can only have one confirmed appointment" belongs to the whole scheduling aggregate, not the slot record alone.

The Aggregate Solution

Define ConsultantSchedule as the Aggregate Root. External code can only book appointments through consultantSchedule.bookSlot(referral) — never by directly updating a slot's status. The root checks availability and enforces the invariant on every write.

DDD often resolves concurrency and data integrity problems not by adding technical locks or retry queues, but by clarifying which object is responsible for enforcing which clinical rule.

The Referral Pathway: One Aggregate That
Enforced the Entire Clinical Workflow

A patient's journey from GP referral to completed consultation involves triage, consultant assignment, appointment booking, and outcome recording — and none of these steps can happen out of order. The system enforces the clinical workflow automatically, making it impossible to skip a step or corrupt the pathway.

ReferralPathway Aggregate Root

Exposes methods like completeTriage(), assignConsultant(), bookAppointment(), recordConsultationOutcome(). Those names mirror the actual steps a clinical coordinator follows — the model reads like the real operational process.

When We Introduced InpatientEpisode

Once a patient was admitted for a procedure, the problem changed completely. The workflow, responsibilities, and rules were no longer outpatient referral management. We introduced InpatientEpisode as a separate model instead of stretching ReferralPathway beyond its design.

One Unit Across Multiple Tables

ReferralPathway spans referrals, triage records, appointments, and consultations in the database — but we treated it as one unit because its consistency rules span all of them. This simplified query logic and removed an entire class of data integrity bugs.

We Stopped Trying to Model the Whole Hospital.
Teams Started Moving Faster.

A hospital is not one system — it is many teams with different concerns, different vocabularies, and different rates of change. We decomposed the platform into seven bounded contexts, each owning its own domain model, database, and team.

Scheduling

Patient, Referral, ConsultantSlot, Appointment, DoctorSchedule, Resource

Patient

Demographics, IdentityMatch, ContactPreference, ConsentRecord

Clinical

Encounter, Observation, Diagnosis, CarePlan, Prescription

Admission

AdmissionEpisode, WardBed, Transfer, Discharge, InpatientStay

Lab

Specimen, TestOrder, LabResult, ValidationRule

Billing

Invoice, Charge, InsuranceClaim, Payment, InvoiceLine, Tax

Notification

Message, Template, DeliveryAttempt, RecipientPreference, Channel

Why Seven?

We started with 3 broader areas, then refined to 7 as the domain revealed clearer seams. Each context maps to a real operational team.

Seven Teams, Seven Contexts

Each bounded context owns its own domain model, database, and team. The Scheduling team ships weekly without coordinating with Billing. Clinical scales independently during peak demand periods.

Seven bounded contexts across the platform
Technical Deep Dive — Why One Model Fails Everyone

The Problem

If you try to build a single unified model for the entire hospital, you end up with a bloated Patient object that means something different to every team. The scheduler needs Patient.referrals. The pharmacist needs Patient.prescriptions. The billing system needs Patient.insuranceCoverage. One model trying to serve all contexts becomes a mess that serves none well.

How We Decomposed the Platform

Each context was extracted into its own independently deployable module with its own database schema, domain model, and team-owned view of the data. The Scheduling team now ships features on a weekly cadence without coordinating with Billing. The Clinical context can be scaled independently during peak demand periods.

Domain-Driven Architecture:
How the Platform Is Structured

Representative production-grade architecture illustrating the domain boundaries, integration patterns, and operational design principles applied during the transformation.

Hospital Management System Domain-Driven Architecture Diagram

7 Bounded Contexts, Each Business-Focused

Each context owns its own aggregates, database, and domain language. No shared schemas. Each context maps directly to a real operational team in the hospital.

Event Flows Drive Integration

Domain events flow between contexts via synchronous (REST/gRPC) and asynchronous (event-driven) communication. No context calls another directly.

Aggregate Ownership Is Explicit

Every aggregate root is named and owned by exactly one context. Aggregate boundaries are the unit of consistency — not database tables.

Business Policies Are First-Class

The Business Rules layer shows named policy objects — SchedulingPolicy, AdmissionPolicy, BillingPolicy, LabPolicy, DischargePolicy — living inside their respective contexts. These are the named, testable rules that replaced hardcoded conditionals in the old system.

We Modelled What Happened,
Not Just What Exists. Integration Got Simple.

The most important things that happen — a referral received, a triage completed, a waiting target breached — are treated as first-class business events. Any part of the system can react to what happened without being tightly coupled to where it happened.

3
New Integrations Added
Without modifying the Scheduling Context
0
Core Changes Required
To onboard each new downstream consumer
10+
Events Drive Everything
Core domain events across all 7 contexts
Domain event flow across the platform

Events as First-Class Citizens

Three new integrations were added in the past year without modifying the Scheduling Context at all. Each new consumer simply subscribes to the published event stream.

Domain Events make the business timeline explicit in code. Instead of asking "what is the current state?", you can ask "what happened, and what should happen next?" — and the answer is always traceable.

Technical Deep Dive — The Four Core Events
01
PatientReferred
A GP submits a referral. The ReferralPathway aggregate is created. The triage clock starts. Downstream: the triage team is notified, the waiting time target is set.
02
TriageCompleted
A clinician assigns a triage band. The pathway moves forward. Downstream: a consultant is matched, a scheduling window opens.
03
AppointmentBooked
A slot is confirmed with a consultant. The patient is notified. Downstream: reminder workflows are triggered, the slot is locked in ConsultantSchedule.
04
WaitingTargetBreached
The mandated waiting window has passed without a completed appointment. Downstream: a compliance alert is raised, a clinical manager is notified, an escalation record is created.

Not Everything Needs an Identity.
Knowing the Difference Simplified Everything.

We systematically classified every object — separating those that need a unique identity over time from those simply defined by their data. This classification alone simplified the data model, reduced database tables, and made the system dramatically easier to test.

Entities: Identity That Persists

An Entity must be tracked as a unique individual over time, even if its attributes change.

  • Patient — even if name, address, or phone number changes, they are still the same patient. Two patients with the same name are NOT the same patient.
  • Referral — it has a lifecycle (created, triaged, assigned, completed) and must be tracked individually.

RULE: Identity, not data, defines equality.

Value Objects: Defined by Their Data

A Value Object has no identity. Two instances with the same data are completely interchangeable.

  • DateRange — two DateRanges covering the same period are identical
  • ContactDetails — describes a patient but has no life of its own
  • TriageBand — a descriptive classification, not a tracked individual

RULE: Value Objects should be immutable. Never mutate — replace.

A common mistake is making everything an Entity "just to be safe." Value Objects are simpler, safer, and easier to test. Default to Value Object unless the domain genuinely requires tracking identity over time.

The Domain Never Knew Which Database We Used.
That Was the Point.

The domain layer has no knowledge of how data is stored. It defines what it needs — and the infrastructure layer delivers it. This gave us freedom to use different storage technologies, migrate schemas without touching business logic, and run the entire domain test suite in under 3 seconds.

4
Stages of Schema Evolution
Monolith → logical separation → isolated schemas → independent services
0
Infrastructure Imports
In the domain layer — the same interface works at every stage

Domain Stays Clean

The repository pattern made a four-stage schema evolution possible — monolith to independent services — without touching a single line of business logic at any stage.

Repository pattern
Technical Deep Dive — The Repository Pattern

Without Repositories (Wrong)

String sql = "SELECT * FROM referrals
  WHERE id = ?";
PreparedStatement stmt =
  conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery();
Referral r = mapToReferral(rs);

The application layer now knows about SQL, connection objects, and result set mapping. Swap PostgreSQL for MongoDB and you're rewriting application code.

With Repositories (Right)

public interface ReferralRepository {
  Referral findById(ReferralId id);
  void save(Referral referral);
  List<Referral>
    findBreachingWaitingTarget();
}

The domain defines what it needs. The infrastructure layer provides the implementation — PostgreSQL today, in-memory for tests, MongoDB tomorrow. The domain never knows or cares.

Repositories make testing trivial. Swap the real PostgreSQL implementation for an in-memory fake in tests — the domain logic runs identically, with zero database setup.

Some Logic Belonged to No Single Object.
We Named It Anyway.

Some business calculations don't belong to any single object — they span multiple parts of the domain. Rather than forcing this logic onto an object that doesn't own it, we give it a name and a dedicated home.

Technical Deep Dive — WaitingTimeBreachRiskAssessor

The Problem We Faced

Calculating whether a referral was at risk of breaching its waiting time target required inputs from multiple objects, and it belonged to none of them. We needed somewhere to put that logic that wasn't forced onto an object that didn't own it.

The Domain Service We Built

public class
  WaitingTimeBreachRiskAssessor {
  public BreachRiskLevel assess(
    Referral referral,
    WaitingTimeTargetPolicy policy
  ) { ... }
}

This service is stateless — it holds no data. It takes a Referral and a WaitingTimeTargetPolicy, then returns a BreachRiskLevel. Clinical managers recognised the name immediately — it came from their vocabulary.

47 Unit Tests, All Named After Clinical Scenarios

WaitingTimeBreachRiskAssessor became one of the most thoroughly tested classes in the platform. We covered every clinical scenario by name, and clinical managers reviewed the test cases directly to confirm they matched real compliance requirements.

Once this logic had its own home, the boundaries became much clearer. It was easier to keep the service focused, and easier to see when a new requirement belonged elsewhere.

We Drew the Map Before Writing the Integration.
It Changed Everything.

When we drew the Context Map, we made explicit what had previously been implicit: which teams depend on which, how they communicate, and who sets the rules. This upfront mapping meant every team knew exactly what they owned and where the boundaries were — before a single line of integration code was written.

Context Maps are not just technical diagrams — they are organisational maps. They reveal which teams depend on which, where translation is needed, and where the real integration risks live.

Context map drawn before integration code

Mapped Before a Line Was Written

Drawing the context map explicitly revealed which teams depended on which, where translation was needed, and where real integration risks lived — before any code was committed.

Shared Kernel? We Decided Against It.

We considered sharing PatientId and EpisodeId definitions between Scheduling and Billing, but the coordination cost was too high. Instead we chose a Published Language so each context could move with less friction.

Anti-Corruption Layer: Protecting Our Domain

We introduced an ACL between the Scheduling Context and the NHS Spine API. Without it, the external system's data structures would have contaminated our domain model. The ACL translates NHS Spine patient data into our own Patient and Referral objects at the boundary.

Scheduling Sends, Billing Consumes

Between Scheduling and Billing, we chose a clear supplier/consumer relationship. Scheduling publishes AppointmentBooked events; Billing consumes them to create a BillingEpisode. The Scheduling team has no knowledge of what Billing does next.

A Stable Event Language We Could Reuse

We defined a stable event schema — PatientReferred, TriageCompleted, WaitingTargetBreached — that any context can consume independently. That published language is what allowed us to onboard three new integrations without touching the Scheduling Context.

What a Well-Built Healthcare Platform
Actually Looks Like

These aren't architectural principles for their own sake. They're the decisions that determined whether the system could pass a compliance audit, handle a government policy change overnight, or onboard a new NHS trust without a six-month integration project. Every one was validated in production.

Earn Domain Fluency

The fastest way we improved the code was by learning the language of the clinicians and operations teams around us. Once we understood the difference between a referral and an appointment, the model stopped feeling generic and started reflecting real work.

Name What Matters

When the waiting time target became a named object — WaitingTimeTargetPolicy — the code and the compliance conversation finally lined up.

Protect the Core

Our clinical rules and pathway logic became much easier to trust once we stopped mixing them with framework noise, HTTP concerns, and database details. That separation kept the important parts readable to the people who understood the domain best.

Let Boundaries Emerge

Some of the most useful boundaries didn't appear in our original diagrams. They showed up when we asked what would break if a referral and its triage record drifted apart — and that led us to the ReferralPathway boundary.

Refactor Toward Insight

Whenever a clinical lead helped us see that appointment and consultation were not the same thing, the model changed with it immediately. Keeping code aligned with the team's current understanding instead of yesterday's assumptions was non-negotiable.

Match Architecture to Complexity

Not every system needed the same depth of modelling. A consultant availability calendar stayed simple, but the moment we were dealing with clinical pathways, triage compliance, and multi-department referrals, the richer approach earned its place.

Classify the Legacy Model Carefully

Sorting every object in the legacy model into entity or value object turned out to be one of the most clarifying exercises we did. It cut the model down significantly, made comparisons more predictable, and made testing much simpler.

Separate Business Logic from Storage

Keeping the domain layer unaware of how data was stored gave us real freedom later. It let us move a context to a different database without rewriting business rules, and that made the whole system easier to evolve.

Give Shared Logic a Home

Some of our most important logic did not belong to any single object, so we gave it a name and a dedicated place to live. Putting WaitingTimeBreachRiskAssessor behind a clear boundary made the logic easier to test, read, and recognise.

Draw the Context Map Early

Drawing the Context Map before writing integration code was one of the best decisions we made. It surfaced team dependencies and integration risks while there was still time to change course, instead of after they had become production problems.

The measure of a well-built system isn't how elegant the architecture is.

It's whether the business can move fast when it needs to — and trust the software when it matters most. Every one of these decisions was validated in production, in a system handling millions of clinical events across NHS trusts.

Ready to build a system your clinical teams can trust?

Let's talk about your platform — whether you're rebuilding a legacy system or starting from scratch, we'll help you get the domain model right from day one. See how our microservices architecture expertise can transform your system.

Book a Free Consultation See Our Work