All posts

Multi-tenant from day one: architecture decisions behind Lueira

Published Dec 7, 20234 min read
  • Engineering
  • Lueira
Multi-tenant from day one: architecture decisions behind Lueira

Key takeaways

  • Multi-tenancy has to be designed in from day one — retrofitting tenant isolation into an existing schema and query layer later is far more expensive.
  • Pick one canonical way to identify a tenant per request and resolve it once at the edge, not query by query.
  • Defend against tenant-leak bugs at more than one layer: an ORM-level default scope plus a database-level constraint or row-level security policy.

When we started building Lueira in March 2023, the very first schema decision we made wasn't about bookings, or payments, or calendars. It was about tenants. Lueira exists to run the day-to-day operations of independent sports schools — ski schools, kayak and diving centers, surf camps — and from the outset we knew it would host dozens of them on the same platform, not one school per deployment. A ski school in the Pyrenees and a dive center in the Canary Islands would eventually share the same database, the same application servers, and the same codebase, while never being able to see so much as a booking that isn't theirs.

That constraint sounds obvious once you say it out loud, but it changes almost every early decision in a way that's easy to underestimate. Multi-tenancy is one of those things that is cheap to build in from day one and brutally expensive to retrofit later, because by the time you notice you need it, every query, every cache key and every background job already assumes a single-tenant world.

1. Why you can't bolt on isolation later

The trap with multi-tenancy is that a single-tenant application "mostly works" even after you start adding tenants — right up until it silently doesn't. If you don't design for isolation from the start, you end up adding a school_id column to your tables well after the fact, then hunting down every query in the codebase to make sure it filters by it. In a codebase with hundreds of queries, hand-auditing each one for a missing filter is not a strategy, it's a hope.

We covered in an earlier post, back when we shipped Lueira's booking engine in June 2023, how much value came from keeping the core booking logic modular rather than hardcoding assumptions about any one school. Multi-tenancy is the natural continuation of that same lesson: the modularity that lets one school configure its own sports, pricing and working hours is the same modularity that lets the platform reason about "which tenant is this request for" as a first-class concept instead of an afterthought bolted onto a growing pile of business logic.

2. Threading tenant identity through every request

Once you accept that isolation has to be designed in, the next decision is how a request even knows which tenant it belongs to. There are a few standard options, and each has a different failure mode:

  • Subdomain (aransport.lueira.com): intuitive for the end customer, and it makes tenant identification happen at the routing layer before any application code runs. The downside is that it couples your infrastructure to DNS and wildcard certificates, and it's awkward for schools that want a custom domain.
  • Header or path segment: flexible and infrastructure-agnostic, but it pushes the responsibility for setting that header correctly onto every client, including your own frontend and any internal script someone writes in a hurry.
  • JWT claim: the tenant travels inside the authenticated session, which means it can't be forgotten by a client, but it also means an authentication bug becomes a tenant-isolation bug — get the claim wrong once and you've handed someone a token that impersonates another school.

None of these is strictly "correct" in the abstract; the right choice depends on where you want the failure surface to live. What matters more than which one you pick is that you pick exactly one canonical mechanism, resolve it once at the edge of the request, and pass the resulting tenant identifier down as an explicit value rather than something query code has to reconstruct.

3. The bug that's uniquely dangerous in multi-tenant systems

There's a specific class of bug that barely matters in a single-tenant app and is genuinely dangerous in a multi-tenant one: a query that forgets to filter by tenant. In a single-tenant system, forgetting a WHERE clause returns too much of your own data. In a multi-tenant system, it returns — or worse, updates — somebody else's.

// Dangerous: compiles fine, passes review if nobody's paying attention,
// and quietly returns every school's confirmed bookings, not just one.
bookings, err := client.Booking.Query().
    Where(booking.StatusEQ("confirmed")).
    All(ctx)

Reasoning about this generically, the fix isn't a single safeguard, it's layering:

// Better: tenant scoping is baked into how you build the query,
// not something you have to remember to add on top of it.
bookings, err := tenantClient.Booking.Query().
    Where(booking.StatusEQ("confirmed")).
    All(ctx) // tenantClient already carries the school scope
-- And a database-level backstop, so even a query that skips the
-- application layer entirely still can't cross tenant boundaries.
CREATE POLICY tenant_isolation ON bookings
    USING (school_id = current_setting('app.current_tenant')::uuid);

An ORM-level default scope catches the mistake before it ships. A database constraint or row-level security policy catches it if the first layer is ever bypassed. Neither one alone is enough; you want both, because the two layers fail independently.

4. Shared infrastructure without a forked codebase

The other half of multi-tenancy is the opposite problem: giving each school enough room to be different without giving them a different codebase. Every school on Lueira has its own branding, its own mix of sports, its own pricing and its own working hours, but they all run on the same deployment and the same database. The moment you fork the code "just this once" for a client with unusual requirements, you've created a maintenance branch that has to be kept in sync by hand forever.

The way out is treating tenant-specific behavior as configuration and data, not code: a school's available sports, pricing rules and schedule live in tables the platform reads and interprets, not in conditionals scattered across the application.

None of this was fully settled on day one — some of it we got right by instinct and some of it we had to correct once real schools started using Lueira side by side. But the core call, that isolation and identity had to be architectural decisions rather than implementation details, is the one choice from those first months I'm most glad we didn't defer.