WRNexus
Back to the blog
engineering 7 min read

Designing multi-tenant workspaces that actually scale

Schema choices, row-level isolation, and the seam between identity and product data — what we learned shipping multi-tenant from day one.

Daniel Park

#engineering · #workspaces · #architecture

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

  1. Database per tenant — the strongest isolation, the most ops surface area, the worst noisy-neighbour mitigation.
  2. Schema per tenant — fewer connections, but every migration runs N times and Postgres performance degrades with thousands of schemas.
  3. 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.