Multi-tenant architecture: one codebase, 2,400 businesses
6 min readNexusHand Engineering
What 'multi-tenant' actually means
In plain terms, a multi-tenant system is one running application and one database serving many independent customers, none of whom can see or affect each other's data. The alternative, single-tenant, gives each customer their own deployment: their own database, sometimes their own servers, spun up and maintained separately. qCommerz is multi-tenant in the first sense. One Next.js frontend, one NestJS API, one PostgreSQL database, and 2,400+ merchant stores running through all three at once.
The term gets used loosely in proposals, so it is worth being precise about it, because the difference changes everything downstream: cost, operational risk, how fast a fix can ship, and how much damage one bad customer or one bad migration can do to everyone else. Sometimes 'multi-tenant' in a sales deck just means 'we deployed the same codebase four times, once per client.' That is not multi-tenancy. It is four single-tenant deployments that happen to share a Git repository.
Why we chose shared-schema for qCommerz
There are three broad ways to structure a multi-tenant database: a database per tenant, a schema per tenant inside one database, or one shared set of tables where every row carries a tenant identifier. Each has a real trade-off, not just a technical preference.
A database per tenant gives the strongest isolation, since a runaway query in one tenant's database cannot touch another's, but the operational cost scales with tenant count. At 2,400+ stores, a database-per-tenant model would mean thousands of connection pools to manage, a migration to run against every one of them on every schema change, and a backup job to verify for each. A schema-per-tenant model softens the isolation story slightly but keeps most of the same problem: a deploy still has to reach every schema.
We picked shared-schema with a tenant identifier on every table because the economics of qCommerz demanded it. Most stores process a few hundred orders a month, on a modest monthly fee. That business only works if adding the next store costs close to nothing in infrastructure, and if a schema change ships once, not thousands of times. Shared-schema is the option that makes a low-cost, high-volume merchant platform financially sane to operate.
How 2,400+ stores stay out of each other's data
Choosing shared-schema is the easy part. The isolation has to actually hold, because the failure mode, one merchant seeing another merchant's orders or customer phone numbers, is not a bug we would patch quietly. It is the kind of incident that ends a platform's credibility overnight. Three things carry the weight:
- Every table holding tenant data carries a store id, and every query against it filters on that column, enforced through a shared query layer, not left to each engineer to remember on every call site.
- Authentication carries the tenant with it. The JWT issued to a store's staff includes the store id as a claim, so the API reads which store a request belongs to from a signed token, not from a parameter a client could edit.
- Storefront requests resolve to a tenant by the Host header. A merchant's custom domain or subdomain tells the application which store is being asked for, before a single query runs.
None of these are exotic. What matters is that they are structural, not incidental: a new engineer cannot accidentally ship a query that skips tenant scoping, because the scoping is built into the thing they call, not into a convention they are supposed to remember. We also run tests designed specifically to fail if a query ever returns another tenant's row, because the honest answer to 'are you sure isolation holds' is a test, not a confidence level.
Plan limits belong in the request path, not the interface
A common shortcut is to gate features in the frontend: hide a button, grey out a menu item, if a customer's plan does not include it. That is cosmetic. Anyone who can open their browser's network tab can call the underlying API directly and find the feature was never actually locked.
Plan gating has to be enforced where the request is actually handled, as a check that runs in the API layer before the handler executes, independent of anything the interface shows. On qCommerz, limits like SKU count, staff seats, custom domain access, and which courier integrations are available are checked in the request pipeline itself. The frontend reflects that decision back to the merchant. It does not make the decision. If the API and the interface ever disagree about what a plan allows, the API wins, because the API is the only one a paying customer cannot route around.
The two risks that actually bite
Two things cause real incidents in a shared-schema system, and neither is exotic security theatre. Both are ordinary operational risk, magnified by sharing.
The first is the noisy neighbour. One store running a flash sale, or a script hitting the API harder than a human would, competes for the same database connections and application capacity as every other store, because there is no hard infrastructure wall between them the way there would be with separate deployments. We handle this with per-tenant rate limits at the API layer, query timeouts so one slow request cannot hold a connection indefinitely, and monitoring that attributes load back to a specific store id, so a spike at two in the morning is something we can name, not something we have to guess at.
The second is migrations. With one shared schema, a migration is not a change scoped to one customer's environment. It is an event that touches every store at the same moment. A lock-heavy ALTER TABLE or a careless default value is not a contained mistake; it is an incident. We treat migrations as additive by default (add a nullable column, backfill it, enforce constraints in a later deploy), test them against production-scale data before they ship, and run anything with lock risk during low-traffic hours.
With a shared schema, a bad migration is not a bug in one customer's environment. It is an incident for all 2,400+ stores at once. We design every migration assuming that is true.
When multi-tenancy is the wrong architecture
Multi-tenancy is not a default good idea. It is a fit for a specific shape of business, and forcing it onto the wrong one creates problems that outlast the build. Three situations where we would not recommend it:
- Regulatory isolation requirements. If a client's compliance obligations require data to be demonstrably separated at the infrastructure level, not just logically scoped, a shared schema cannot honestly satisfy that, no matter how good the tenant-scoping code is.
- One dominant tenant. If a single customer is large enough that its traffic, support needs, and one-off feature requests would shape the whole system's roadmap, it usually deserves its own deployment or its own contract, not a seat at a shared table built for many smaller customers.
- Wildly divergent feature needs. If tenants need fundamentally different data models rather than the same product configured differently, forcing them into shared tables produces a schema full of nullable columns and conditional branches that helps nobody.
A short checklist, if an agency is pitching you this
Founders hear 'multi-tenant architecture' in a pitch fairly often, because it sounds sophisticated. A few direct questions separate a real answer from a slide:
- What does 'tenant' mean concretely in your schema: is it a column enforced everywhere, or a word in the proposal?
- Where is isolation enforced: in the data and API layer, or only in what the interface happens to show?
- What happens when one customer sends ten times the normal traffic? Is there a specific mechanism, or just an assumption it will not happen?
- How do you test that a migration cannot take every customer down at once?
- Under what conditions would you tell me not to build this as multi-tenant?
If the last question gets a confident 'there are none,' that is itself an answer. qCommerz runs 2,400+ stores on this architecture because it fits the business shape it was built for. That is also the honest limit of the recommendation: it is a fit for a specific problem, not a universal answer.