Chapter 7 · Foundations

The smallest version worth shipping

The four things a multi-tenant platform cannot launch without, the six that look essential and are not, and why the first version must include the isolation test.

6 min read

A first version of a multi-tenant platform is not a first version of an ordinary product with tenancy added. Some things that are normally deferrable are load-bearing here, and some things that feel mandatory can wait a year.

The four that cannot be deferred

1. The tenant scope, enforced structurally

Not "remember to add tenant_id". A query layer where an unscoped read does not compile. Deferring this means retrofitting hundreds of call sites later, and Chapter 4 covered how that bill actually arrives.

// Day one. Not a helper — the only way to reach the database. export function forTenant(tenantId: TenantId) { return { products: () => db.select().from(products).where(eq(products.tenantId, tenantId)), customers: () => db.select().from(customers).where(eq(customers.tenantId, tenantId)), } } // And the thing that makes it stick: the raw client is not exported. // Reaching past this module is a lint error, not a judgement call.

2. Row-level security, even with one tenant

The temptation is overwhelming: there is one tenant, the policies are untestable in any interesting way, and it slows every migration slightly. Add it anyway.

The reason is not today's risk. It is that RLS added later must be added to a schema already full of data and already queried by code written without it, and every policy you write then is a guess about what the application currently relies on. Added on day one, every subsequent table inherits the pattern.

3. Membership, not just users

A users table with a tenant_id column is the single most common day-one mistake, because it is correct until the first person needs access to two tenants — and then it requires a user-visible migration of logins.

-- Wrong, and it will be wrong on a Tuesday eighteen months from now. create table users ( id uuid primary key, email text not null unique, tenant_id uuid not null references tenants(id) -- a person has exactly one ); -- Right from the start. The join table costs almost nothing today. create table users ( id uuid primary key, email text not null unique ); create table memberships ( user_id uuid not null references users(id), tenant_id uuid not null references tenants(id), role text not null, primary key (user_id, tenant_id) );

4. The isolation test

Before the second tenant exists. A test that creates two tenants, writes rows for both, and asserts that reading as tenant A returns zero of tenant B's rows across every table.

// The test that must exist before launch, because the thing it checks is the // one failure you cannot apologise your way out of. test.each(ALL_TENANT_SCOPED_TABLES)('%s does not leak across tenants', async (table) => { const a = await createTenantWithRows(table, 3) const b = await createTenantWithRows(table, 3) const asA = await readAs(a, table) expect(asA).toHaveLength(3) expect(asA.map((r) => r.tenantId)).not.toContain(b.id) })

Driving it from a list of tables rather than writing one test per table matters: a new table added without being registered should fail the suite. Chapter 38 makes that list derive from the schema so it cannot go stale.

The six that can wait

FeatureWhy it waits
Roles beyond owner/memberGranular permissions are a long conversation. Two roles cover the first fifty tenants, and the membership table already has the column.
Invitations by emailAdd a member by email address directly. Token invitations, expiry and resend are a week of work for a flow used twice per tenant.
Per-tenant brandingA logo and a colour. Everything beyond that is a design system problem, not a tenancy one.
SubdomainsPath-based tenancy (/t/acme/...) works, is simpler to host, and is trivial to migrate from. Chapter 15 shows the migration.
Self-serve plan changesA support person changing a plan is fine at low volume, and the proration edge cases in Section 7 are where the bugs live.
An admin consoleA read-only database view and a runbook. A console that can act on any tenant is a security surface you do not want before you need it.

What the first week actually produces

tenants, users, memberships, products, customers
  + a tenant resolver on every request
  + the forTenant() query layer, raw client unexported
  + RLS policies on all five tables
  + the isolation test, driven by the schema
  + one vendor onboarded through Connect, taking one real payment

That last line is the one people skip, and it is the only one that proves the architecture. A platform that has never moved a single real dollar through a tenant's account has not tested the hard half of itself — verification states, the fee deduction, the webhook ordering. One live transaction of a few dollars finds more than a month of reading.

Ship the narrowest product that exercises the full width of the architecture. One tenant, one plan, one customer, one real payment — but every layer involved, end to end.

What to refuse

Two requests will arrive in the first month and both should be declined:

  1. "Can this one customer have a slightly different schema?" No. The first per-tenant schema divergence is the end of one codebase, and the request is almost always satisfied by a JSON settings column.
  2. "Can we skip verification for this vendor, they're a friend?" No. The half-onboarded tenant is already a state you have to handle (Section 5); a deliberately unverified one that can take money is a compliance problem with your name on it.

Key points

  • Four things cannot be deferred: structural tenant scoping, row-level security, a membership join table, and the isolation test.
  • The raw database client must not be exported. Enforcement by lint beats enforcement by review.
  • Add RLS with one tenant. Added later, every policy is a guess about what existing code relies on.
  • users.tenant_id is the classic day-one mistake. The join table costs nothing now and avoids migrating people's logins later.
  • Drive the isolation test from a schema-derived table list, so a new unregistered table fails the suite.
  • Six things can wait: granular roles, email invitations, branding, subdomains, self-serve plan changes, and an admin console.
  • Include one real payment through one real vendor account in the first week. It exercises verification, fee deduction and webhook ordering, which no amount of reading does.
  • Refuse per-tenant schema divergence and refuse unverified tenants taking money.

Next: The data model on day one — five tables, every column justified.