Technology

Multi-Tenant SaaS Architecture: Lessons From Building CampSuite

I have been running CampSuite as a multi-tenant product for years now. Here is what the architecture decisions actually cost me, and what I would do differently if I started again tomorrow.

Multi-Tenant SaaS Architecture: Lessons From Building CampSuite

Multi-tenant SaaS architecture is one of those decisions you make early and then live with for the rest of the product's life. I made mine building CampSuite, and I have spent years since either quietly congratulating myself or quietly wishing I had done something differently. Mostly the second one, if I am honest.

This is not a theoretical piece about database sharding patterns you will find in a systems design textbook. This is what actually happened when a small bootstrapped team had to make tenancy decisions with limited time, limited budget and a product that needed to ship. If you are building a SaaS product and staring at this decision right now, I want to save you some of the pain.

What multi-tenancy actually means in practice

Multi-tenant architecture means one codebase and one set of infrastructure serving many customers, with each customer's data kept logically separate. The alternative is single-tenant, where each customer gets their own dedicated instance and database. Most people think this is purely a technical question. It is not. It is a business question wearing a technical costume.

The business question is: how much operational overhead can you tolerate per customer, versus how much isolation and customisation do your customers actually need? Get that wrong and no amount of clever database design saves you.

The decision I made and why

With CampSuite I went with a shared database, shared schema approach. Every tenant's data lives in the same tables, distinguished by a tenant identifier column that gets filtered on every single query. This is the cheapest option to run and the fastest to build early on, which mattered enormously when I was bootstrapping with no outside investment and needed to keep hosting costs low while proving the product had legs.

The alternative approaches were a shared database with separate schemas per tenant, or fully separate databases per tenant. Both give you stronger isolation. Both also give you a maintenance nightmare the moment you need to run a migration across three hundred schemas instead of one, or provision a new tenant instantly instead of running a script.

For a product with modest data volumes per customer and a need to onboard new tenants instantly through self-service signup, shared schema was the right call. If I was building something handling far larger data volumes per tenant, or serving enterprise customers who contractually demand physical data separation, I would have made a different choice from day one.

What I got right

Baking the tenant identifier into the data model from the very first migration, rather than bolting it on later, was the single best decision. Every table that holds tenant data has that column, it is indexed properly, and it is enforced at the query layer rather than trusted to application logic that someone might forget to write correctly at two in the morning.

I also built tenant isolation testing early. Every so often we run automated checks that attempt to access another tenant's data through the API and confirm they fail. This sounds obvious. It is astonishing how many SaaS products never actually verify this and just hope the application code is consistently correct everywhere, forever, across every developer who ever touches it.

Keeping configuration and feature flags per tenant, rather than per deployment, meant I could roll features out gradually and roll them back for a single customer without redeploying anything. That flexibility has paid for itself many times over when something has gone wrong for one customer and I needed to isolate the blast radius quickly.

What I got wrong

I underestimated how much noisy neighbour problems would matter. A single tenant running an enormous report or bulk import can slow the database down for everyone else on the same shared infrastructure. I did not build proper resource governance early enough, and retrofitting query throttling and background job isolation after the fact was considerably more painful than designing for it from the start.

I also did not think hard enough about the eventual customer who would ask for genuine customisation beyond configuration. Some customers eventually want workflow changes or integrations that do not fit the shared model cleanly. I have had to build increasingly clever configuration layers to accommodate this rather than accepting some customers might need a different tier of service entirely.

The biggest mistake, in hindsight, was not documenting the tenancy model clearly enough for new developers joining the team. Every new hire has to relearn, usually through a near miss, that every query touching tenant data needs that filter applied. This should have been enforced structurally through the data access layer far earlier than it was, rather than relying on code review to catch it every time.

What I would do differently starting again

I would build the tenant filtering into the data access layer itself from day one, so it is structurally impossible to write a query that leaks across tenants, rather than relying on developer discipline. Row level security at the database layer, or a repository pattern that automatically scopes every query, removes an entire category of very embarrassing bugs.

I would also build resource governance and rate limiting per tenant much earlier. It is far easier to design that in from the start than to add it once customers are already relying on consistent performance and you are trying to throttle one customer's usage without them noticing or complaining.

Finally, I would decide upfront which parts of the product genuinely need per tenant customisation and design a proper extension mechanism for those, rather than discovering the need reactively and bolting configuration options on one customer request at a time. That reactive pattern is how you end up with a settings table that has grown into something nobody fully understands.

The advice I would actually give you

If you are deciding on tenancy architecture right now, do not over engineer for scale you do not have yet. Shared schema with a properly enforced tenant identifier will take most SaaS products a very long way, and it is far cheaper to run and iterate on than premature separation you do not yet need.

But do the boring, unglamorous things early: enforce tenant isolation structurally rather than by convention, test it automatically, and build basic resource governance before you need it rather than after a customer's bulk import brings the whole platform to a crawl for everyone else. I learned most of this the hard way. You do not have to.

If you want a longer view on the surrounding decisions, I have written before about why microservices are usually the wrong call for small SaaS products and about how to choose a tech stack for your SaaS product in the first place. Tenancy is just one piece of the same puzzle.

More from the blog

Technology7 min read

Why Microservices Are Wrong for Most Small SaaS Products

Read more
Technology7 min read

Disaster Recovery for Small SaaS Businesses: What You Actually Need

Read more
Technology6 min read

How to Choose a Tech Stack for Your SaaS Product

Read more