CARIBBEIN
← All notes

Engineering note · decision

One database, nineteen worlds

Nineteen vertical operating systems share one operational Postgres. The classic choices are a tenant column or a database per tenant. We took the middle path — a schema per OS — and it holds because of two boring rules about migrations.

August 2026 · migration counts probed

The shape of the problem

Our tenants are not customers — they are whole vertical products. A charter operation, a restaurant back-office and a sports club manager do not share a data model; they share a platform: one identity, one tenancy record per provider, one push system, one job runner. And they are rebuilt in parallel lanes, which adds a constraint most multi-tenancy write-ups skip: two lanes must be able to verify at the same time, each seeding and truncating test data, without touching each other.

Three ways to slice a database

Option What it buys What it costs
Tenant column in shared tables Cheap joins, one migration stream, easy cross-tenant queries. Every query must remember the filter, forever. Nineteen unrelated data models flattened into one namespace. Two lanes seeding the same tables collide.
Database per OS Total isolation; a lane can do anything without risk. Nineteen connection pools, nineteen migration streams — and the platform tables (one identity, one tenancy) become a federation problem, which is a distributed system you did not order.
Schema per OS (chosen) Namespace isolation where lanes collide; one database where the product needs oneness. A lane owns its schema outright. Schema selection must happen somewhere on every request, and migrations need namespacing rules — both solvable once, centrally.

What is genuinely shared lives in a dedicated platform schema: identity, tenancy, the ticketing core, push subscriptions, job heartbeats. Everything vertical lives in that OS's own schema. Isolation where people collide; oneness where the product demands it — a provider signs in once, not nineteen times.

Selection you cannot forget

The REST layer over Postgres selects a schema per request by header. The dangerous version of that sentence is “every query passes its schema” — because forgetting on one query out of four hundred is how an OS half-writes into another's tables, and the failure mode is quiet. So no individual query is allowed to remember. An OS module declares its schema once, at the top, and takes pre-bound calls from the seam:

// the shape: bind once, never per query
const db = forSchema('restaurantos');
// every db.get / db.rest call now carries the right
// read-header or write-header automatically

Row-level security, from the first table

Schemas separate operating systems from each other. Inside one OS, providers still share tables — and that boundary is enforced by the database, not by application code remembering a WHERE clause. Every table ships with row-level policies in the same migration that creates it. Retrofitting RLS onto a live table means archaeology on every existing query; writing it on day one costs a paragraph. The generic shape:

alter table bookings enable row level security;

create policy provider_owns_rows on bookings
  using (provider_id = current_provider_id());

Belt and braces: the application layer also scopes every read and write by provider as its first argument — not because we distrust the database, but because two independent layers have to fail together before data leaks, and one of them is checked by a guard.

The two boring rules that make it hold

Schema-per-OS fails in practice through migrations, not queries. Two rules, each written after an actual incident, both enforced by a CI guard:

  • Every migration file lives under a namespace, and only touches tables its namespace owns. A file loose in the migrations directory has no declared owner — which is exactly how an early OS migration came to create two platform tables it had no business creating. Ownership is written down, and the guard reads it.
  • Numbers never repeat inside a namespace — and repeat across namespaces on purpose. Our applied ledger once carried the same number twice, from a single file applied as two versions; the guard makes that impossible now. Across namespaces, both the platform and an OS can own a migration with the same number, and replay order breaks the tie: platform first, always.

And the rule that makes both rules testable: CI replays the entire migration history from zero on every build. Today that is platform migrations and for the first rebuilt OS. A migration that only works because of the state it happened to run against is a migration that will fail on the day you least want it to — the day you stand up a fresh region.

Check what you can

The schemas are not reachable from outside, by design — that is rather the point of the essay. What you can check: the /geek diagram carries the same claims with status dots, the numbers here load from eng-facts.json with a visible probe date, and the guard that enforces the migration rules is described — scar and all — in Ten guards, ten scars.