Work / BadaBhai
Multi-tenancy without a migration
Invited teammates on an employer account saw zero jobs, zero credits and zero candidates. The fix re-scoped fifteen business tables to the organisation — with no new column, no backfill and no change to the API.
Measured outcomes
- 15
- business tables moved to organisation tenancy
- 0
- schema migrations or backfills required
- 3
- rollout modes: off, shadow and on
The problem
Employers on BadaBhai post jobs, buy credits and unlock candidate contact details. When an employer invited a teammate, the invitation worked and the team screens showed the right organisation. But every business screen showed nothing: zero postings, zero credits, zero candidates, zero unlocks.
Fifteen business tables — postings, applicants, credits, unlocks, the ledger, orders and more — were keyed to the individual login, not the organisation. Team management resolved the invitee to the organisation; every business query still filtered on their own login. It was a P0.
Constraints that ruled out the obvious fix
The textbook answer is to add an organisation column to each table and backfill it. Here that was the risky option:
- Migrations are applied by hand, and a merge deploys code without them. The ORM names every column explicitly in reads and writes, so a new column on a hot table means every read returns an error if the code lands before the migration. Twelve hot tables meant twelve chances to break production.
- A partial switch is worse than none. Postings scoped to the organisation but credits scoped to the login produce purchases that fail silently.
- Backfills on live financial tables are a risk of their own.
Options considered
| Option | Cost or risk |
|---|---|
| Add an organisation column to 12–15 tables | Manual migrations across hot tables; errors on every read if deploy precedes migration; large backfill |
| Put the organisation in the login token | Stale after membership changes; every route must re-resolve anyway |
| Scope each repository ad hoc | Guarantees a partial switch; no single place to check |
| Use an existing immutable key as the tenant | Needs a resolver and discipline, but no schema change and no backfill |
What I built
Every business request
- Signed-in teammate
- Tenant resolverMode: off → the login · shadow → log differences · on → the organisation anchor
- Branded tenant keyThe only type repositories accept
- 15 business tablesExisting columns, no migration
Enforced by a test: no code outside the resolver may create a tenant key. A read-only census must pass before the mode can be switched on.
Every organisation already had an immutable anchor: the account that created it. That anchor is one-to-one with the organisation, cannot be null, is unique, and cannot be deleted while the organisation exists — and it was already stored in the existing columns. So the anchor became the tenant key.
- One resolver turns a request into a branded tenant key type. Repositories accept only that type, never a raw identifier from a request body, path or token.
- One switch with three modes. Off resolves to the login itself (the old behaviour). Shadow logs what would differ. On resolves to the organisation. Because every query goes through the resolver, the switch cannot be partial.
- A static test refuses any type assertion to the tenant key outside the resolver module. It also documents, in plain words, what the brand cannot stop: a value typed as
any. - Invitation rules return one neutral conflict response, log which rule fired, consume no token and emit no event.
- A read-only census checks the data in a single read-only transaction and exits non-zero if the conditions for switching are not met.
An organisation column can still be added later as a purely additive change. Nothing in this design closes that option.
Testing
The defect was captured first as a test expected to fail, proving it existed. Then came unit tests for the resolver and modes, invitation rules tested against real Postgres, static wiring tests, boot tests and census tests. The database test gate grew from 20 to 21 files. A security review blocked the change once; I closed the four blind spots it found with fixture tests before it passed.
A known race between checking and writing during invitation acceptance was registered as an explicit risk and made an entry condition for the next phase, rather than declared fixed.
Trade-offs
- The anchor key does double duty: an account identifier also acts as a tenant identifier. That is less self-explanatory than a dedicated column.
- Moving a membership between organisations is not supported. It is not a current requirement.
- There is no adoption metric yet, because the switch is a controlled rollout.
What I learned
Constraints are design inputs. “We cannot safely migrate fifteen hot tables” led to a smaller change with a stronger guarantee: one place where tenancy is decided, and a test that fails if anyone tries to decide it somewhere else.
Where the numbers come from
- 15 business tables moved to organisation tenancy: Decision record and five merged pull requests, 7–10 Oct 2026.
- 0 schema migrations or backfills required: Same.
- 3 rollout modes: off, shadow and on: Same.