Direct answer
We run Clinizy Care on one shared MongoDB database with shared collections: every tenant-owned document carries a tenantId, and a Mongoose plugin injects that filter into every read, write, count and aggregation, failing closed when no tenant is in context. At ₹1,999 a month for a solo clinic, a database per tenant would cost and operate out of proportion; a strict, tested guard with tenant-first indexes fits the economics.
We built Clinizy Care — hospital management software for clinics, nursing homes and small hospitals in India — on one shared MongoDB database with shared collections. Every tenant-owned document carries a tenantId, every request runs inside its clinic's tenant context, and a Mongoose plugin injects the tenant filter into every query, update, delete, count and aggregation, failing closed when no tenant is present. Build-breaking tests keep the guard and its indexes honest. Here's why we chose shared collections over a database per clinic, how the guard works, and what broke along the way.
Key takeaways
- Match the tenancy model to the price point. At ₹1,999 a month for a solo clinic, a database or schema per tenant would cost and operate out of proportion.
- Make scoping the default, not a habit. A base plugin applied through a schema factory scopes 70 of our 98 data models automatically; cross-tenant work has to opt out with an explicit flag.
- Fail closed. With no tenant in context, the injected filter only matches documents that have no
tenantId, so tenant-owned data can't come back by accident. - Cover every operation, not just reads. Our first guard only scoped find queries, until an internal audit flagged that updates and deletes weren't covered.
- Prove isolation against a real database. Mocked models can't show that tenant A can't see tenant B. A 23-case integration suite on an in-memory MongoDB replica set can.
- The blast radius is shared too. One crashed process or one wrong time zone hits every clinic at once.
What does multi-tenancy mean for a clinic HMS?
In a multi-tenant HMS, every clinic is a tenant. It runs on the same application and infrastructure as every other clinic, but it must only ever see its own patients, visits, bills and prescriptions. That boundary has a lot of surface area to hold across. The 11 modules of Clinizy Care — OPD queue management, GST billing, pharmacy and inventory, IPD and bed management, digital prescriptions, lab and diagnostics, appointment scheduling, patient records, reports and analytics, AI clinical documentation (in early access) and WhatsApp for clinics — all read and write a single shared patient record. Reception, the doctor, the pharmacy, the lab and the IPD ward all work on the same data, inside the same tenant line.
The customers shape the architecture as much as the data does. Clinizy Care is built for practices up to roughly 50 beds, with plans starting at ₹1,999 a month (excluding GST). It runs in the browser with nothing to install, the owner's dashboard works on a phone, and the interface is available in English, Hindi and Hinglish. Many small tenants, a low price and zero installation decided the tenancy model. The wider product story is in how we built Clinizy Care; this post stays on the data layer.
Why did we choose shared collections over a database per tenant?
Because at our price point, isolation has to come from code and tests rather than from infrastructure. The AWS Well-Architected SaaS Lens describes three broad patterns: a silo model, where tenants get dedicated resources such as a separate database; a pool model, where tenants share infrastructure to achieve economies of scale; and a bridge model that mixes the two.
For a solo clinic paying ₹1,999 a month, a database or schema per tenant would cost and operate out of proportion: every signup becomes a provisioning job, and every migration and index build runs once per tenant. So we chose the pool model: one database, shared collections, a tenantId on every tenant-owned document, and indexes that lead with tenantId.
| Model | How tenants are separated | Where isolation lives | Cost and operations per tenant | Fit for a ₹1,999/month clinic |
|---|---|---|---|---|
| Database per tenant (silo) | A separate database for each clinic | Infrastructure | Highest: provisioning, migrations and monitoring per database | Out of proportion |
| Schema or collections per tenant (bridge) | Shared server, separate schemas or collections per clinic | Naming and access rules | High: migrations and indexes repeated per tenant | Still heavy for a solo practice |
| Shared collections (pool) | One set of collections, a tenantId on every document | The application's tenant guard | Lowest: a new tenant is new data, not new infrastructure | Fits, if the guard is strict and tested |
The trade-off is explicit. In a pooled design, a single query that forgets its tenant filter can return another clinic's data. The architecture is only as safe as that guard, so we treat it as load-bearing infrastructure with its own tests, not as a convention developers must remember.
Want to build this — the right way?
Brynex Labs designs and ships production-grade AI agents, automation, and software for teams in India and worldwide. Book a free scoping call and we'll tell you honestly what's worth building — and what isn't yet.
How does each request know which clinic it belongs to?
Through a tenant context that the auth middleware sets once and every layer below reads implicitly. The middleware verifies the signed JWT (RS256), then validates its payload — including the tenant id — with a Zod schema from a shared package that both the API and the web app import. That package also holds the role-permission matrix. Only after both checks pass does the middleware call runWithTenant(tid, next), which runs the rest of the request inside Node's AsyncLocalStorage.
According to the Node.js documentation, AsyncLocalStorage allows "storing data throughout the lifetime of a web request", and the store is accessible to asynchronous operations created inside the callback but not outside it. A service six calls deep, or a Mongoose hook, can ask which tenant it's serving without a tenantId argument threaded through every signature — and a parameter nobody has to pass is one nobody can forget.
Public routes resolve the tenant differently
Not every request has a signed-in user. Public appointment booking resolves the tenant from the clinic's slug, and the OPD TV display in the waiting room resolves it from a display token. The data layer's rule doesn't change with the entry point: no resolved tenant, no tenant data.
Multi-branch clinics are multiple tenants
A clinic with three branches is three linked tenants. Staff get linked-clinic sign-in and a clinic switcher, but underneath, each branch is its own tenant and each request carries one tenant at a time. The guard never has to reason about hierarchies.
How does the Mongoose tenant guard work?
Every tenant-owned model is built with a schema factory, createTenantScopedSchema, which applies a base plugin. 70 of our 98 data models go through it. The other 28 are deliberately platform-global: tenants, plans, configuration, the marketing blog and similar. For any new tenant-owned model, being scoped is the path of least resistance.
Mongoose has four kinds of middleware — document, model, aggregate and query — according to its middleware documentation, and hooks are registered per operation. A complete guard needs hooks across all of them:
| Operation family | What the guard does |
|---|---|
| find, findOne and the findOneAnd* variants | Adds the tenant filter before the query runs |
| update, delete and replace operations | Scopes the write to the current tenant |
| count, countDocuments, distinct | Scopes the count or the distinct values |
| aggregate | Prepends a $match stage on tenantId |
| insertMany | Throws if no tenant is available |
| save | Applies the tenant scope to document saves |
The aggregate hook relies on a documented feature: the Mongoose docs describe Aggregate#pipeline() as "useful for adding stages to the beginning of the pipeline from middleware." With the tenant $match first, every later stage only sees one clinic's documents.
The plugin also injects a default maxTimeMS of 30 seconds on queries. The MongoDB manual describes this as a cumulative time limit beyond which MongoDB terminates the operation — a cheap ceiling in a database every clinic shares.
Failing closed
What matters most is what happens when there is no tenant. This is the heart of the filter injection, simplified from the real code:
// Simplified from the Clinizy Care tenant plugin
const injectTenantFilter = (query) => {
if (query.getFilter().tenantId) return;
const tenantId = resolveScopeTenantId(query);
query.where(tenantId ? { tenantId } : { tenantId: { $exists: false } });
};
If a tenant is in context, the query is scoped to it. If not — a missing context, a background job, a plain bug — the filter becomes { tenantId: { $exists: false } }, which only matches documents that have no tenantId at all. Tenant-owned documents always have one, so the query returns nothing instead of everything. An empty result is a bug report; a full result is a data leak.
Cross-tenant work is still possible, but never accidental. Scheduled jobs and the platform admin console have to opt out of scoping with an explicit flag, so every unscoped query is visible in the code as a deliberate choice. Scoped is the default.
How do you prove tenant isolation in tests?
With two lanes: a structural check that fails the build, and an integration suite that runs against a real database. We added the second because mocked-model unit tests couldn't prove isolation: a mock returns whatever you tell it to, so it can't show whether the filter that reaches MongoDB excludes another tenant's documents.
The structural lane. A unit test fails the build if any tenant-scoped model lacks an index whose first key is tenantId. The first key is the point: according to the MongoDB manual on compound indexes, a compound index supports queries on its index prefix, and a query that skips the leading field can't use it that way. Since every scoped query filters on tenantId, tenant-first indexes keep the guard's filter servable by an index.
The real-database lane. A 23-case integration suite runs against a real in-memory MongoDB replica set. It proves that tenant A can't read, update, delete, count or aggregate tenant B's data, that queries fail closed when no tenant is in context, and that transaction rollback behaves. It has to be a replica set because MongoDB supports multi-document transactions on replica sets and sharded clusters, per the MongoDB transactions documentation.
Both lanes sit inside a larger suite: thousands of automated tests across the API (Jest) and the web app (Vitest), with coverage gates enforced in CI.
What broke along the way?
The first guard only covered reads
The first version of the plugin scoped find queries and nothing else. An internal audit flagged that updates and deletes weren't covered. Because Mongoose registers middleware per operation, "the plugin handles tenancy" quietly meant "the plugin handles reads". We extended the guard to updates and deletes, then to count, distinct and insertMany, and moved the remaining tenant-owned models onto the plugin. Lesson: list every operation your ODM exposes and decide what the guard does with each. The table above is that list.
A setting silently removed a filter
A background job was meant to process active tenants only. Its filter used a field that wasn't in the schema, and a Mongoose strictQuery setting stripped it. According to the Mongoose guide, when strictQuery is true Mongoose filters out query properties that aren't in the schema (in Mongoose 7 the default is false). For us it happened silently: the filter became empty, and the job walked every tenant ever created. The tenant guard couldn't catch it, because a job that iterates over tenants is cross-tenant work by definition. Lesson: test the query that actually reaches the database, not the one you wrote.
One crashed job is every clinic's outage
An unhandled promise rejection inside a scheduled job could crash the API process. Since Node.js 15, the default mode for unhandled rejections is throw: without an unhandledRejection hook, the rejection is raised as an uncaught exception, per the Node.js 15 release notes. By default, Node then prints the stack trace and exits with code 1, according to the Node.js process documentation. In a single-tenant app that's one customer's bad afternoon. In a multi-tenant system it's every clinic at once. Every scheduled job now runs inside a guard, with a MongoDB-based lock so only one instance runs it.
Midnight in Mumbai isn't midnight in UTC
Our financial-year and day-bucket calculations initially used UTC. Indian clinics live in IST, which is UTC+5:30, so anything between midnight and 5:30 a.m. IST falls on the previous UTC date — and late-night bills could land on the wrong day. The financial year makes the edge sharper: under the General Clauses Act, 1897, a financial year "shall mean the year commencing on the first day of April", so a bill raised at 1:00 a.m. IST on 1 April is still 31 March in UTC and would be bucketed into the previous financial year. Business dates are now computed explicitly in IST. More billing edge cases are in our GST billing engine lessons. (This is an engineering note, not tax advice.)
Want to build this — the right way?
Brynex Labs designs and ships production-grade AI agents, automation, and software for teams in India and worldwide. Book a free scoping call and we'll tell you honestly what's worth building — and what isn't yet.
The honest takeaway
Shared-collection multi-tenancy moves isolation out of infrastructure and into code, which means the code has to be treated like infrastructure. For Clinizy Care, that meant a scoped-by-default schema factory, a guard that covers every operation and fails closed, tenant-first indexes enforced by a test, and an integration suite that proves isolation against a real database. The failures we hit were ordinary ones — a missing hook, a stripped filter, an unhandled rejection, a time zone — and each left behind a lesson we now design around.
Some things we keep simple on purpose. The whole system runs on AWS in the Mumbai region (ap-south-1). Side effects — WhatsApp, SMS, email, PDFs and reports — go onto AWS SQS queues, each with a dead-letter queue, drained by a separate worker so the request path stays fast (more in running the WhatsApp Business API at clinic scale). The live OPD queue streams to waiting-room screens over server-sent events from a single process; scaling it horizontally would need a pub/sub layer, and that's a deliberate trade-off.
The same tenant model — scoped by default, fail-closed, tested against a real database — is what we bring to client SaaS builds through our AI-native software engineering practice. If you're building for hospitals and clinics, our healthcare software engineering team is the one that builds and runs Clinizy Care, and the Clinizy Care case study has the wider picture.
Technologies Covered
Written by
Abhi PandeyFounder & CTO
Abhi Pandey is the Founder & CTO of Brynex Labs. He leads product and engineering for Clinizy Care, the hospital management software Brynex Labs builds and runs for India's clinics and nursing homes, and for the AI agents and SaaS platforms the studio builds for clients. He writes about multi-tenant SaaS architecture, applied AI engineering, and shipping reliable systems to production.
Read Next
WhatsApp Business API at Clinic Scale: What We Learned
The API call is the easy part; the system around it is the work. Sending clinic messages over the WhatsApp Cloud API for many clinics from one SaaS comes down to a sender model with a safe fallback, utility-only templates in each language you support, a queue with dead-letter handling, platform-enforced guardrails and atomic quotas, and webhooks verified against the raw request.
Lessons From Building a GST Billing Engine for Clinics
Most GST billing bugs are data bugs that surface as tax errors. Store money as integer paise, attach tax rates to the product and service master, decide CGST/SGST versus IGST in one place, back-calculate GST from MRP, apply discounts before tax, compute dates in IST, and make finalized bills immutable.
Cloud vs On-Premise in 2026: A Practical Decision Guide
For most businesses in 2026, public cloud is still the right default because it trades capital cost for speed and elasticity. On-premise wins only once your workloads become large, steady, and predictable. This guide gives you the numbers and a break-even test.