Users and Orgs
One model, three products
Instagram has no workspaces. Linear won’t let you in without one. Notion gives you a personal one and lets you join ten more. Three products that look like three different data models — actually one model with three different policies. That’s the whole trick of this playbook: get the model right once, and B2C vs B2B vs “single player now, teams later” stops being a schema question and becomes a config question.
The naive schema is a user table with everything FK’d to user_id. It works for Instagram forever. It breaks the day a second person needs to see the same data — and by then every table, every query, and every Stripe subscription points at one person.
The four jobs of “user”
A user row quietly does four unrelated jobs:
| Job | Question it answers | Concrete form |
|---|---|---|
| Identity | who is typing? | email, passkey, sessions |
| Attribution | who did this? | ”Priya edited this 2 hours ago” |
| Ownership | whose data is this? | the company’s — not Priya’s |
| Payment | who pays? | the company card |
In pure B2C the four coincide in one human, so gluing them to one table seems fine. In B2B they split: humans log in and act, but the company owns the data and pays the bill. The design question is never “B2C or B2B?” — it’s which of these four jobs share a row.
The job that forces the split is ownership: ownership must attach to something that outlives any one person. Priya quits; her projects stay. If access hangs off her user row, offboarding her means either keeping a ghost login alive or reassigning forty thousand rows to whoever inherits them. Neither is a feature.
The schema
Three tables, from day one, even for a single-player app:
user id, email, name, auth fields -- identity
organization id, name, slug, stripe_customer_id -- ownership + payment
membership user_id (nullable), org_id, role -- owner | admin | member
project org_id ← ownership: this is what grants access
created_by ← attribution only — points at membership
(“Tenant” is the generic word for the ownership boundary — the box all of a customer’s data lives inside. Here, the organization is the tenant.)
Two rules, and they’re the whole model:
- Every domain table FKs
org_id. Projects, documents, API keys, uploads — all of it. - People-columns on domain tables are attribution, never access.
created_byandassigned_torender names; they grant nothing. Access flows only through membership → org.
Why does created_by point at membership, not user? Because “who did this?” is always asked inside a team, and the answer is a member of that team, not a global login. Bullet Train (the Rails SaaS framework, which converged on this same model) makes membership the attribution target, and it dissolves three problems at once:
- Offboarding without rewriting history. When someone leaves, copy their name and avatar onto the membership row and null its
user_id— a tombstone. Every “edited by Priya” still renders, and Priya can hard-delete her account without touching any team’s data. - Assign work before they accept. An invite creates a membership with no user yet. You can assign tasks to “Sam” the moment you decide to add him, not after he gets around to clicking the email.
- Per-team identity. Priya is “Priya S.” in her employer’s workspace and “Sharma Consulting” in a client’s — display name lives on the membership.
That nullable user_id encodes the member lifecycle in one column: invitation pending and no user = invited; user set = active; neither = tombstone of someone who left.
The path
Day 0 is unconditional. Everything after is a gate: if this event happens, then make this move. Watch the last column — after day 0, no gate ever alters a domain table. That’s the payoff for the entry fee.
Day 0 — every app, no exceptions:
- The three tables:
user,organization,membership. - Signup creates user + org + owner membership in one transaction.
- Every query scoped by
org_id, enforced in one place (query helper or Postgres RLS) — a forgottenWHERE org_id = ?is a cross-tenant leak, so gate it with code, not review discipline. -
stripe_customer_idon the organization, even before you charge anyone. - One canonical term in code (
organizationis the common pick) — the UI label is a display string you can change; the table name is load-bearing in every migration, FK, and API route.
The gates:
| # | If | Then | Schema change |
|---|---|---|---|
| 1 | Product is usable by one person (Instagram, a solo tool) | Personal org: auto-created at signup, member cap 1, never shown in the UI | none |
| 1′ | Product is useless without colleagues (Linear) | Signup is “create or join a workspace” — no invisible org, the word is in the UI from day one | none |
| 2 | Product has assignments, mentions, or comments | created_by → membership | day-0 choice |
| 2′ | People-columns are pure audit trail | created_by → user, ghost user on deletion (GitHub’s @ghost) | day-0 choice |
| 3 | A user asks “can I add my cofounder?” | Promote the personal org in place: rename it, lift the member cap, ship the invite flow. Zero rows move; pricing is a plan change on the org | none |
| 4 | A user needs a second workspace (agency, consultant) | Allow create/join of more orgs, add a switcher, put the active org in the URL (/acme/settings — shareable links, two tabs = two orgs, no “wrong workspace” edits) | none |
| 5 | Someone asks for groups inside the workspace (“can we have a design team?”) | Apply the tenant test: own bill + own data boundary? No → team (org_id, name) + team_membership. Never nest orgs — recursive tenancy is the cliff where pricing, permissions, and every query grow a tree-walk | additive |
| 6 | An enterprise buyer asks for SSO | SSO, SCIM, domain capture (“anyone @acme.com joins Acme”), audit log — all attach to the org, which now controls who may log in | additive |
Gates 1 and 2 are forks you pick once at day 0. Gates 3–6 are events; each arrives on its own schedule, and the answer is always ready.
Now compare against the naive schema: gate 3 alone becomes alter-every-table, rewrite-every-query, backfill-every-row — live, with customers on it. Vercel spent 2023 doing exactly that migration in public, converting every personal account into a Team so they could stop maintaining two ownership models. Start with one.
Decisions that bite
- Invite by email, not user_id. The invitee may not have an account yet:
invitation (org_id, email, role, token, expires_at)plus the unclaimed membership it attaches to. One sub-decision: may an invite be claimed by a different email than it was sent to? Bullet Train allows it (invited at work address, signs up with personal) — pragmatic, but a forwarded link is now a credential. Allow it with single-use expiring tokens; revisit when enterprise asks for domain restrictions. - Offboarding is a tombstone, not a delete. Copy name and avatar onto the membership, null its
user_id. Access gone, history intact, and account deletion never cascades into a team’s data. - Don’t hand-roll the machinery. Invites, roles, and org switching are solved: better-auth’s organization plugin, Clerk Organizations, WorkOS. Your job is the two rules and the gates, not the plumbing.
Prior art
Bullet Train’s Teams should be an MVP feature (Andrew Culver) reaches the same model from Rails-land and is the source of the membership-attribution and tombstone patterns above. One place to part ways with it: Culver argues for teams as a visible MVP feature. The schema is the MVP feature; the UI isn’t. A single-player product showing “Your Team” in settings is paying UX for optionality that policy can carry invisibly.