Multi-tenancy is one of those decisions that is cheap to get right on day one and expensive forever after. We’ve migrated two products from “one customer per database” to “shared schema with tenant columns” and neither rewrite is something we’d recommend on a dare.
Here is the model we use in WRNexus, with the trade-offs we’ve made visible.
Three common shapes
- Database per tenant — the strongest isolation, the most ops surface area, the worst noisy-neighbour mitigation.
- Schema per tenant — fewer connections, but every migration runs N times and Postgres performance degrades with thousands of schemas.
- Shared schema with a tenant column — one table per concept, every
row carries a
workspace_id, isolation is enforced in the query layer.
WRNexus is option 3. We’re not unique in this — Stripe, Notion, and
Linear all use variations of the same pattern. The reason: it’s the
only option where a single ALTER TABLE lands for every customer at
once, and you can stand up a million-tenant system on the same
hardware as a hundred-tenant one.
The two columns every table has
Every table in the WRNexus product schema has the same two columns at the front:
CREATE TABLE document (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspace(id),
-- ... domain columns ...
);
CREATE INDEX ON document (workspace_id, created_at DESC);
workspace_id is the tenant boundary. It’s the first column of every
index, and the first WHERE clause of every query. There is no
“global” read in the product surface — even the search endpoint scopes
itself to the caller’s current workspace before it ever hits the
database.
Enforcing isolation in code, not policy
Row-Level Security in Postgres is tempting, and we evaluated it. The problem: every connection has to be reconfigured with the active tenant, which adds an extra round-trip for pooled clients (PgBouncer, RDS Proxy) and surprises in monitoring tools that don’t set the session variable.
We landed on a simpler rule: every repository method takes a
workspace_id as its first argument, and there is exactly one query
builder helper that adds the WHERE clause. Code review catches
deviations, and we have a linter that fails CI if a query against a
tenant-scoped table doesn’t include the predicate.
Identity is shared, product data is not
The seam that took us the longest to get right is the one between
identity (a user, owned by the SSO subsystem) and product
data (anything tenant-scoped, owned by a workspace). A user can
belong to many workspaces; a workspace owns its own product data
independent of who’s currently logged in.
The bridge is the workspace_membership table:
CREATE TABLE workspace_membership (
user_id uuid NOT NULL REFERENCES "user"(id),
workspace_id uuid NOT NULL REFERENCES workspace(id),
role text NOT NULL,
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, workspace_id)
);
Authorisation reads from this table; everything else in the product
just sees a workspace_id and never has to think about which user is
present.
What about noisy neighbours?
The honest answer: monitor and alert. We instrument every query with
the workspace_id it ran against and ship that to Honeycomb. If one
tenant is doing 90% of the read volume on a shared table, we see it
within a minute and either rate-limit them or move them onto a
dedicated read replica.
For our enterprise customers who genuinely need physical isolation, the same image runs against their own Postgres instance. The product code doesn’t know — the only difference is the connection string.
Closing rule of thumb
Pick the shared-schema model. Make workspace_id the first column of
every table and every index. Enforce isolation in your repository
layer, not your reader’s brain. The other 95% of your product code
shouldn’t have to know multi-tenancy exists.