The problem multi-tenant healthcare SaaS has to solve
A clinic management product looks simple from the outside: appointments, patients, prescriptions, billing. The hard part isn't features — it's the tenancy model.
Real clinics broke my early assumptions fast:
- One doctor often owns multiple clinics (a downtown branch + a hospital affiliation).
- A receptionist can work at clinic A in the morning and clinic B in the evening.
- A patient may have visits at multiple clinics of the same doctor — same medical history.
- Each clinic has its own subscription lifecycle — active, trial, expired, suspended.
- Walk-ins outnumber scheduled appointments in this market, so the queue is the product, not the calendar. A naive "one tenant = one customer" model breaks on day one. This post walks through the architecture I shipped for TabeebHub: a NestJS + Prisma + Postgres stack with a shared database, row-level tenant scoping, role-based access, and clinic-scoped subscriptions. --- ## The stack (and why) | Layer | Choice | Why | |-------|--------|-----| | API | NestJS | Modules + decorators map cleanly to clinics, staff, visits, billing | | ORM | Prisma | Type-safe queries; middleware hook is perfect for tenant scoping | | DB | Postgres | Mature row-level security, JSONB for prescription payloads, strong constraints | | Auth | JWT | Doctor + staff sessions, embedded role + clinic claims | | Frontend | Next.js 14 (App Router) | RSC, i18n routes for Arabic/English | | Queue | BullMQ + Redis | Appointment reminders, WhatsApp OTP, subscription expiry jobs | | Payments | Stripe + local PSPs | Stripe globally; local wallets (Fawry, Vodafone Cash) in Egypt | Nothing exotic. The architecture wins or loses on how the boundaries between tenants are enforced, not on the framework. --- ## Tenancy model: shared DB, scoped rows Three real options I considered:
- DB per tenant — strongest isolation, operational nightmare at 100+ clinics.
- Schema per tenant — better, but Prisma + migrations get awkward.
-
Shared DB, tenant column on every row — most operationally sane for B2B SaaS at our scale.
I picked option 3 with a strict rule: every tenant-owned table has a
clinicIdforeign key, and every query is scoped by it at the ORM layer — not at the controller. Simplified schema:
model Doctor {
id String @id @default(cuid())
email String @unique
phone String @unique
clinics Clinic[]
createdAt DateTime @default(now())
}
model Clinic {
id String @id @default(cuid())
ownerId String
owner Doctor @relation(fields: [ownerId], references: [id])
name String
isActive Boolean @default(true)
subscription Subscription?
staff ClinicStaff[]
patients Patient[]
visits Visit[]
createdAt DateTime @default(now())
@@index([ownerId])
}
model ClinicStaff {
id String @id @default(cuid())
clinicId String
clinic Clinic @relation(fields: [clinicId], references: [id])
userId String
role StaffRole
isActive Boolean @default(true)
@@unique([clinicId, userId])
}
model Visit {
id String @id @default(cuid())
clinicId String
clinic Clinic @relation(fields: [clinicId], references: [id])
patientId String
doctorId String
status VisitStatus // WAITING | WITH_ASSISTANT | WITH_DOCTOR | DONE
startedAt DateTime?
finishedAt DateTime?
createdAt DateTime @default(now())
@@index([clinicId, status])
}
enum VisitStatus {
WAITING
WITH_ASSISTANT
WITH_DOCTOR
DONE
CANCELED
NO_SHOW
}
A few decisions worth pointing out:
-
clinicIdis everywhere. Visits, prescriptions, invoices, audit logs. No tenant-owned row exists without it. -
isActiveonClinicis enforced at the service layer. Deactivated clinics can't accept new visits, even via direct API calls.
- Subscription lives on the clinic, not the doctor. A doctor with 3 clinics can have 3 different plans.
Tenant scoping: Prisma middleware, not controller params
The single biggest source of bugs in multi-tenant systems is forgetting to filter by tenant. Junior devs do it. So do tired senior ones.
I refuse to rely on every developer remembering. Instead, the request-scoped tenant context flows through Prisma middleware:
// prisma/tenant.middleware.ts
import { Prisma } from '@prisma/client';
import { AsyncLocalStorage } from 'async_hooks';
export const tenantContext = new AsyncLocalStorage<{ clinicId: string }>();
const TENANT_SCOPED_MODELS = new Set([
'Visit',
'Patient',
'Prescription',
'Invoice',
'AuditLog',
'ClinicStaff',
]);
export const tenantMiddleware: Prisma.Middleware = async (params, next) => {
const ctx = tenantContext.getStore();
if (!ctx?.clinicId) return next(params);
if (!TENANT_SCOPED_MODELS.has(params.model ?? '')) return next(params);
const { clinicId } = ctx;
if (params.action === 'create' || params.action === 'createMany') {
const data = params.args.data;
if (Array.isArray(data)) {
params.args.data = data.map((row) => ({ ...row, clinicId }));
} else {
params.args.data = { ...data, clinicId };
}
}
if (
params.action === 'findUnique' ||
params.action === 'findFirst' ||
params.action === 'findMany' ||
params.action === 'update' ||
params.action === 'updateMany' ||
params.action === 'delete' ||
params.action === 'deleteMany' ||
params.action === 'count'
) {
params.args = params.args ?? {};
params.args.where = { ...(params.args.where ?? {}), clinicId };
}
return next(params);
};
A NestJS interceptor sets the tenant context per request:
// common/interceptors/tenant.interceptor.ts
@Injectable()
export class TenantInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
const clinicId = req.user?.activeClinicId;
if (!clinicId) return next.handle();
return new Observable((sub) => {
tenantContext.run({ clinicId }, () => {
next.handle().subscribe(sub);
});
});
}
}
What this buys me:
- A new developer writing
prisma.visit.findMany()literally cannot leak another clinic's visits. - Cross-tenant queries (admin dashboards, internal reports) explicitly opt out by skipping the interceptor — visible in code review.
- All tenant scoping logic lives in one file. Tests are trivial.
What this does not buy me: protection from the doctor switching
activeClinicIdin their JWT to a clinic they don't own. That belongs to the next layer. --- ## RBAC: visit status is the source of truth Healthcare RBAC is more than "is this user a doctor or a receptionist." It depends on the state of the visit. Examples: - A receptionist can edit patient info before the visit starts, not after.
- A doctor can create a draft prescription only while the visit is
WITH_DOCTOR. - Nobody can modify a finalized prescription — only append addenda.
We compute a permission matrix from
(userRole, visitStatus):
type Action =
| 'EDIT_PATIENT'
| 'CHECK_IN'
| 'START_VISIT'
| 'CREATE_DRAFT_PRESCRIPTION'
| 'FINALIZE_PRESCRIPTION'
| 'EDIT_FINAL_PRESCRIPTION'
| 'ISSUE_INVOICE';
const matrix: Record<StaffRole, Record<VisitStatus, Action[]>> = {
DOCTOR: {
WAITING: [],
WITH_ASSISTANT: [],
WITH_DOCTOR: ['CREATE_DRAFT_PRESCRIPTION', 'FINALIZE_PRESCRIPTION'],
DONE: [],
CANCELED: [],
NO_SHOW: [],
},
RECEPTION: {
WAITING: ['EDIT_PATIENT', 'CHECK_IN', 'START_VISIT'],
WITH_ASSISTANT: ['EDIT_PATIENT'],
WITH_DOCTOR: [],
DONE: ['ISSUE_INVOICE'],
CANCELED: [],
NO_SHOW: [],
},
ADMIN: { /* full */ },
};
export const can = (
role: StaffRole,
status: VisitStatus,
action: Action,
): boolean => matrix[role][status].includes(action);
The frontend uses the same matrix (shared TS package) to hide the action; the backend uses it to enforce the action. Single source of truth.
Mistake I made: I first wired RBAC from role only. Within two weeks, a receptionist accidentally edited a finalized prescription. Visit status had to become part of the permission key.
Subscriptions per clinic, not per account
Most B2B SaaS bills by account. Clinic SaaS shouldn't.
Why: a doctor with two clinics often runs them as separate businesses (different staff, different P&L). Forcing a single subscription means upselling fails and downgrades are painful.
So:
model Subscription {
id String @id @default(cuid())
clinicId String @unique
clinic Clinic @relation(fields: [clinicId], references: [id])
plan Plan // STARTER | GROWTH | PRO
status SubStatus // TRIAL | ACTIVE | PAST_DUE | CANCELED | EXPIRED
trialEndsAt DateTime?
renewsAt DateTime?
providerId String? // Stripe/local PSP reference
createdAt DateTime @default(now())
}
A SubscriptionGuard blocks tenant-scoped writes when the clinic's subscription is not in (TRIAL, ACTIVE). Reads are still allowed in PAST_DUE for a grace window — clinics losing patient access mid-day would be unforgivable.
The 14-day trial is automatic on clinic creation. No credit card. This drove conversion higher than any pricing page change I tried — see pricing if you're curious.
What I'd do differently
- Start with the AsyncLocalStorage tenant context on day one. I spent a week refactoring 40+ controllers when I bolted it on later.
- Postgres row-level security as a belt-and-suspenders layer. Application-level scoping is enough operationally, but RLS would catch the one rogue migration script I'd inevitably write.
-
Audit log every write to
PrescriptionandInvoicefrom day one. I added it after a real "who changed this dosage?" support ticket. Should have been there at v1. -
Pick a queue earlier. I shipped appointment reminders as
setTimeoutchains. They survived dev. They did not survive a deploy.
5. i18n the database, not just the UI. Arabic patient names broke 3 separate PDF generators that assumed Latin-1 widths.
When this architecture stops working
This shared-DB, tenant-scoped approach is fine until roughly:
- ~1,000 active clinics, or
- A clinic asks for a data residency guarantee (e.g. a hospital chain that needs an isolated DB), or
- You hit a noisy neighbor query (one giant clinic's reports starve everyone). At that point you add read replicas per region, then dedicated DBs for enterprise tenants, and keep the shared pool for the long tail. The application code shouldn't change — that's the whole point of the AsyncLocalStorage scoping. --- ## TL;DR
- Treat tenancy as infrastructure, not a feature.
- Scope tenants in the ORM via middleware + AsyncLocalStorage, not in controllers.
- Make visit status a first-class input to your permission system.
- Bill the unit your customer treats as a business (the clinic), not the account.
- Build the boring layers (audit, queues, i18n) early — every one of them I delayed cost me real money later. If you're building healthcare SaaS in an emerging market, the localization and workflow decisions matter as much as the architecture. Happy to dig into any of those in a follow-up — drop a comment. --- Abdullah is the founder of TabeebHub — a cloud clinic management platform for doctors and clinics in Egypt and MENA: appointments, live patient queue, digital prescriptions, patient portal, and billing in Arabic and English.
Top comments (0)