Skip to main content
Back to Blog
·7 min read·By Emanuele Pugliese

The Multi-Tenancy Decision You Can't Take Back

Listen to this article

Every agency I’ve seen drifts into products the same way. You build something for a client, it’s good. A second client wants something close to it. By the fourth build you realise you’ve been rebuilding the same app with a different logo each time.

So you decide to do it properly — one product, many customers. That’s the day you walk into multi-tenancy, whether anyone said the word or not.

I’ll skip the textbook definition. The one thing worth taking seriously before you write a line of code is this: multi-tenancy is the decision that is genuinely painful to reverse.

You can swap frameworks, rewrite the frontend, or change hosting providers. What you cannot easily do is go back and re-thread the concept of “which customer does this row belong to” through a database that already contains live client data.

I spent years in payments. A row leaking from one party to another isn’t a bug report — it’s a regulator’s afternoon. That instinct is the one thing I carry into even the smallest agency product: decide tenancy first. Almost everything else stays cheap to change.

A tenant is simply one customer of your product and everything that belongs to them — their users, data, settings, and configuration. Multi-tenancy is the art of letting many of them share the same system while each feels like it’s theirs alone.

Shared, or Separate?

Strip away the jargon and every architectural choice comes down to the same question, asked repeatedly at the database, application servers, cache, and search layers: Do tenants share this resource, or get their own copy?

Model Isolation Cost & Complexity Best For Risk Level Reversibility
Silo Everything separate High Large/regulated enterprise clients Very low Easy
Pool Shared + logic Low Many small clients High (if done poorly) Very hard
Hybrid Mix of both Medium Most growing agencies Medium Medium

Most products that survive long enough end up in the hybrid model: share the cheap layers aggressively, isolate the expensive-to-leak ones, and let different clients sit in different places depending on what they pay.

The mistake is thinking you have to pick one model globally. You don’t.

Where It Actually Gets Decided: The Data

Sharing servers is easy. Data is where it bites.

Database per tenant (Silo)

The cleanest option. One bad migration only affects one client. Backups and data export when a client leaves are trivial. Compliance conversations are straightforward.

The cost is operational: every schema change must fan out across every database. At a few hundred tenants you need automation, or you’ll spend your life running migrations.

Shared tables with tenant_id (Pool)

The cheap option — and the right default for an agency selling to lots of small businesses.

One set of tables. A tenant_id column on everything. Every query filters on it.

Here’s the part I’d tattoo on a junior developer: the day someone forgets that filter, you’ve handed one client’s data to another.

That’s not a glitch you patch quietly. Under UK GDPR (and similar data protection laws in most jurisdictions), it can become a notifiable breach.

flowchart TD
    A[Incoming Request] --> B[Tenant Resolver<br/>subdomain / custom domain / API key]
    B --> C[Application Data Layer<br/>Automatically injects tenant filter]
    C --> D[Postgres with Row-Level Security]
    D --> E[Only returns rows<br/>belonging to this tenant]

    style C fill:#e0f2fe,stroke:#0369a1
    style D fill:#fef3c7,stroke:#b45309

The reliable way is to make forgetting the filter impossible:

  1. Build a data layer that automatically injects the tenant filter.
  2. Back it with Postgres Row-Level Security (RLS) so the database itself refuses to return another tenant’s rows even if your code forgets.

Belt and braces.

The leaks that actually happen

The dangerous leaks almost never come from an obvious SELECT *. They come from:

  • A cache that forgot to include the tenant in its key
  • A nightly export job that ran without tenant scoping
  • A search index that was never designed with customer isolation in mind

Scope those too, or they will find you.

A near-miss story: Early in my payments career we once caught (in staging) a reporting job that was about to email every merchant a CSV containing all transactions from the previous month — not just their own. The tenant_id filter had been accidentally dropped during a refactor. It never reached production, but it was close enough to make the point stick: you must make the safe path the only easy path.

The Bit That’s Specific to Agencies

A generic SaaS company can get away with yourapp.com/acme. You usually can’t — because your clients hired an agency precisely so the product would feel like theirs.

That makes custom domains (e.g. book.theirshop.co.uk pointing at your platform with automatic certificates) less of a nice-to-have and more of the reason they’re paying you a premium.

Build support for it early. Retrofitting domain handling later is fiddly and expensive.

The same applies to branding. Logos, colours, and custom wording should be treated as tenant data, not code you redeploy. When done right, onboarding a new client’s look and feel becomes someone filling in a form instead of you doing a release.

Onboarding and Commercial Reality

How does client number 50 actually come into existence?

If the answer is “I run a checklist by hand,” you’ve quietly capped your growth at the limit of your own attention. Even a rough automated “create tenant” flow — config, subdomain, API keys, defaults — turns onboarding from a small project into a non-event.

The commercial trick, once you have a mix of clients, is to let pricing and isolation match each other:

  • Keep your many small clients in the pool (this protects your margin).
  • Offer a dedicated setup (own database, sometimes own deployment) as the premium tier for clients who are big enough or regulated enough to pay for it.

The cleanest way to grow into this is to start everyone in the pool and build the ability to lift a single heavy client out into their own silo when they’re worth it. Starting fully siloed and trying to consolidate later is much harder.

flowchart LR
    subgraph Small Clients
        direction TB
        P1[Client A] --> Pool[Shared Pool<br/>tenant_id + RLS]
        P2[Client B] --> Pool
        P3[Client C] --> Pool
    end

    subgraph Enterprise Clients
        direction TB
        S1[Client X] --> Silo1[Own Database]
        S2[Client Y] --> Silo2[Own Database]
    end

    Pool -.->|Lift when ready| Silo1

What I’d Actually Do

If you’re an agency turning client work into a product for small businesses, here’s the practical path:

  1. Start pooled — shared tables with tenant_id on everything. Nothing else makes financial sense at those price points.
  2. Enforce scoping in the data layer and again in the database (Postgres RLS). A forgotten filter should be impossible.
  3. Resolve the tenant in one place at the edge — whether that’s a subdomain, custom domain, or an API key embedded in a widget on the client’s own site.
  4. Treat all branding and limits as tenant data so you’re not redeploying to change a colour.
  5. Design the system so you can move a single big client onto their own database later without rewriting the application. That escape hatch is your future premium tier.

The thing I would avoid above all is putting the decision off.

Tenancy is the one piece you cannot comfortably bolt on once real customers and their data are already in the system. “We’ll sort it properly after launch” is how an afternoon’s design choice becomes a year of careful, risky migrations.

Spend the thinking now.

Future you — the one who isn’t explaining a data leak to a nervous client — will be grateful.

Related Articles

Comments

Comments are powered by GitHub Discussions. Sign in with GitHub to leave a comment.

Basket is empty

Stay in the loop

Get architecture insights, product updates, and practical engineering tips.

PuglieseWeb LTD is registered in England and Wales. Company No. 17078772.