engineering

Why Branches Should Be First-Class Entities in Hospital Software

An engineering perspective on multi-tenant healthcare data — what 'first-class branches' means at the database, query, and JWT layers, and why retrofitting is so painful.

By CareHubOS team

This is an engineering post. If you're choosing hospital software for a group, the previous post is the better starting point. This one is for the people who'll build or integrate against that software — and care about how multi-tenancy is actually implemented underneath.

The federation trap

Most hospital management systems were designed for a single facility. The data model assumes one tenant. When the vendor (or the customer) later needs multi-site support, they have two options:

  1. Federate — run a separate install per site, optionally with sync.
  2. Retrofit — add a branch_id column to every table and hope nothing leaks.

Option 1 we covered separately. Option 2 is where the engineering pain lives, and where almost every "we added multi-tenancy" project ends up.

The reason retrofitting is brutal is that multi-tenancy is a cross-cutting concern. It touches every query, every cache key, every JWT, every audit log, every API route, every report. Bolting it on means walking every line of code and asking: does this respect branch scope? It's not a single PR; it's an ongoing review process that never ends because someone always forgets.

The only sane alternative is to make branches first-class entities from day one.

What "first-class" means at each layer

Data model

Every entity that belongs to a branch carries a BranchId foreign key. Not a shadow column — a real, modelled, indexed property. Two consequences:

  • The constraint is enforceable. You can't insert a row without a branch.
  • Queries that filter by branch use the index. Performance scales.

The handful of entities that don't belong to a branch — global catalogs, system audit, the branches table itself — are explicitly exempted. There's no middle ground.

Query layer

This is where most retrofits fail. The naïve approach is to add WHERE branch_id = ? to every query. It works until someone writes a new query and forgets.

The robust approach is a global query filter at the ORM layer (EF Core in our case, but similar primitives exist for Sequelize, Django, etc.). Every query against a branch-scoped entity gets the predicate automatically:

modelBuilder.Entity<Patient>()
    .HasQueryFilter(p => _branchContext.AuthorisedBranchIds.Contains(p.BranchId));

This single line means a developer can write _context.Patients.Where(p => p.Status == "ACTIVE") and the result is automatically scoped to branches they're authorised for. There's nothing to remember.

The escape hatch (IgnoreQueryFilters()) is reserved for two cases: cross-branch admin endpoints that deliberately need group-wide data, and identity lookups (login, password reset) where the actor's branch hasn't been resolved yet.

Auth / claims layer

The branch context can't come from the request body — too easy to forge. It comes from the JWT claim set, issued at login (or branch-switch) by the auth service:

{
  "sub": "USR2026000023",
  "branchId": "BR_AC",
  "branchIds": "BR_AC,BR_SU,BR_KU",
  "role": "Doctor"
}

branchId is the active branch — the one the user is currently working out of. branchIds is the authorised set — every branch they can switch to. The global query filter checks branchIds.Contains(entity.BranchId); the active branchId is used when stamping new records (insert this row at the branch the user is currently in).

The frontend never decides which branch the user is in. The server decides, signs it, and ships it as a claim. Client-side forgery is impossible without the signing key.

Audit layer

Every audit log entry records the branch context the action happened in. Not the actor's primary branch — the active branch at the time. This matters when a roving doctor works Monday at one site and Tuesday at another: their audit trail should reflect where they were on each day, not where their HR record says they belong.

API surface

Routes have three flavours, by convention:

  • Branch-scoped reads (GET /patients/list) — automatically filtered to the active branch.
  • Cross-branch admin reads (GET /admin/group/patients) — explicitly opt out of the filter, gated by a specific permission.
  • Identity reads (GET /auth/me) — bypass entirely. Branch context isn't yet resolved when these run.

A reviewer should be able to tell from the route which flavour they're in. Routes that quietly mix branch contexts are how data leaks happen.

Why retrofitting hurts

Adding the data-model bit is easy: ALTER TABLE on every relevant table, set the FKs, backfill. A weekend's work.

What's hard is the dependent code:

  • Every repository.GetX() method needs to know whether it should be filtered.
  • Every cache key needs the branch in it (a cached patient list for Branch A cannot be served to Branch B).
  • Every JWT issued before the change has no branch claim and now produces ambiguous queries.
  • Every audit log row written before the change has no branch context and breaks group-level reports.
  • Every existing background job that did _context.Patients.ForEach(...) is now silently wrong.

We've seen retrofits take 18 months. Native-from-day-one is days.

The CareHubOS implementation

Our data model has ~90 entities. ~60 of them implement an IBranchScoped interface that requires a BranchId property and a Branch navigation. The OnModelCreating registration registers each as branch-scoped via:

ApplyBranchQueryFilter<Patient>(modelBuilder);

The helper adds the FK, the index, and the global query filter. One line, three guarantees.

AuthorisedBranchIds lives on the IBranchContext service, populated from the JWT claim. When a developer writes a query, the filter just works. When they want to bypass it (a group dashboard endpoint, for example), they have to write IgnoreQueryFilters() explicitly — which is exactly the right level of friction for "you're doing something unusual."

We added this on day one because we knew retrofitting later would be a years-long project. We've never regretted it.

Takeaway

If you're evaluating hospital software and you might ever operate more than one site: ask the vendor how branches are modelled. If the answer involves "we run a separate install per branch" or "we replicate records across sites," walk away from the conversation as politely as possible. If the answer involves "every entity has a branch FK, queries filter automatically at the ORM layer, the active branch lives in a signed JWT claim," lean in.

The data model decision compounds. It's the easiest thing to get right at the start and the hardest thing to fix later.

Want to see this in your branches?

A 30-minute walkthrough tailored to your group's size and module mix.

Book a demo