Case study

Turning one hospital's software into a business that sells to many.

The clinical system already worked. The hard problem was making it sellable — one codebase, many hospitals, each paying for exactly the modules they use, none of them able to see another's data.

A working hospital system doesn't automatically become a product.

The starting point was a fully working, single-tenant Hospital Management System — a custom PHP MVC codebase (deliberately no framework, no Composer dependency), about 35 tables in one MySQL schema, covering OPD, IPD, Pharmacy, Lab, and billing for one hospital. It worked. Patients were tracked, bills were generated, staff had role-based logins.

The business problem showed up the moment a second hospital wanted it: this system had no concept of a "tenant." Selling it to hospital number two meant either standing up a second full copy by hand, or accepting that two hospitals' patient and billing data would live in the same tables. Neither is a business — the first doesn't scale past a handful of customers, and the second is a liability no hospital should accept for its patient records.

One decision that everything else follows from: separate databases, not shared tables.

The foundational call was tenant isolation via a separate MySQL database per hospital, provisioned automatically from the existing schema at signup — rejected the more common shared-database-plus-tenant_id approach on purpose. For medical and billing data, complete isolation matters more than the convenience of one shared schema: clean backup/restore/delete per customer, no risk of a query bug leaking one hospital's patients into another's report, and — not incidentally — a natural fit for the offline desktop app, where one database cleanly maps to one tenant's sync scope.

The trade-off, accepted deliberately: a schema change now has to roll out across every tenant database, not one, and connection handling has to scale with tenant count instead of staying a single fixed connection. Tenant routing followed the same "make it a real architectural decision, not a shortcut" pattern — subdomain-per-tenant (cityhospital.app.com) for the web app, and a registration-key pairing scheme for the desktop app, which has no subdomain to log into on first run.

The business layer — plans, billing, referrals — was built as its own Master Panel backed by a separate Master database, deliberately kept apart from every tenant database. A platform admin manages tenants, plans, and payments from one place that a hospital's own admin never sees, and that isolation is structural, not just a permission check.

What's actually running under it.

Backend
A custom PHP MVC core — routing, RBAC, CSRF protection, security headers, rate limiting, and idempotency handling, with no Composer dependency by choice.
Tenant data
One MySQL database per hospital, provisioned from a single install.sql schema at signup — around 35 tables per tenant, covering patients through billing.
Platform data
A separate Master DB for tenants, plans, invoices, referrals, and platform audit logs — structurally apart from every tenant's own data.
Billing
Razorpay for checkout, with gateway credentials encrypted at rest (AES-256-GCM) and a subscription lifecycle that auto-suspends a tenant after a 7-day due period plus a 3-day grace window, and auto-reactivates it the moment a payment clears.
Desktop app
Flutter (Windows first), talking to a token-based API layer. Offline writes queue locally with a client-generated UUID and push on reconnect; receipt and bill numbers are only ever assigned server-side at sync time, so two offline devices can never collide on the same number.
Security
Server-side re-verification of device limits and offline grace on every check-in, anomaly logging for tampering attempts, release-build code obfuscation, and TLS certificate pinning support for production deployment.

The bug that wasn't there: two offline front desks and a duplicate patient.

The scariest-sounding problem going in was two front-desk PCs, both offline, both potentially generating the same receipt number. It turned out not to be a distributed-systems problem at all, once offline-create was designed correctly: a device never assigns its own patient ID or receipt number — it leaves that field blank locally and the server fills it in only once the record actually syncs. Two devices working offline simultaneously have nothing to collide over, because neither one is generating a number in the first place.

What was left to solve was narrower and more human: pure visibility, so the same patient doesn't get typed in twice by two different staff members who can't see each other's in-progress work. That became a small UDP broadcast on the hospital's local network — each offline device announces its pending records every ten seconds, tenant-scoped so two hospitals sharing a building don't see each other's patients, and self-filtered so a device doesn't confuse its own broadcast for a peer's. The result is a small "also being registered nearby" panel, not a distributed database.

What's live, and what's honestly still on the roadmap.

Live and verified against a real database, not just designed: tenant creation and provisioning, the plan builder and module gating, Razorpay billing with auto-suspend/reactivate, the referral program (₹500 / ₹200 account credit, applied automatically to the next invoice), platform and tenant audit logs, tenant impersonation for support, and the Flutter desktop app's offline queueing, LAN peer visibility, and device licensing.

Still ahead: a mobile app on the same Flutter codebase (deliberately scheduled after desktop, admin-focused rather than a front-desk replacement), cross-platform announcement tickers, and production TLS certificate pinning verified against a real certificate rather than just built and inert. Billing emails currently go out through Mailgun for invoices and payment events only — a broader notification system beyond billing is a deliberate scope cut, not an oversight.

If I were starting the multi-tenant conversion today, I'd add an updated_at column to every tenant table from day one. Its absence forced a workaround — a separate sync_log table just to give the desktop app a reliable "what changed since I last synced" cursor — which works, but it's the kind of retrofit that's cheaper to avoid than to build around.

Running something similar for a different industry, or need a real multi-tenant SaaS conversion?

Get in touch