Multi-tenancy is a decision you make on day one
Every multi-tenant bug looks identical from the outside: someone saw data that wasn't theirs. They're all caused by the same thing — a query that forgot which organisation was asking.
Starting from nothing
An employer-facing product built from an empty repository: organisations sign up, verify an email, onboard, publish opportunities with draft and visibility controls, review applicants, and have listings close automatically after a deadline. Multiple organisations, one deployment, one database.
The first commit wasn't a feature. It was CI, a Dockerfile and an end-to-end test harness. On a greenfield repo that infrastructure is the cheapest it will ever be, and it means nothing is ever deployed by hand — including the very first demo, which is exactly when someone is most tempted to just push a build from their laptop.
Where tenancy belongs
The tempting place to enforce it is the controller: read the organisation from the session, pass it down, filter the results. That works until the day something reaches the data layer from somewhere else — a background job, a migration, a webhook handler, an admin script. Then the filter isn't there, because the filter lived in a controller nobody invoked.
Tenant scope belongs in the layer that owns the data, expressed the same way ownership is:
// Tenant is part of the question, not a check afterwards.
async function listOpportunities(orgId, filters) {
return opportunities.find({ organisation: orgId, ...filters });
}
// So a caller physically cannot ask a tenant-less question.
async function findOpportunity(orgId, id) {
const found = await opportunities.findOne({
_id: id, organisation: orgId,
});
if (!found) throw new NotFoundError(); // not 403: don't confirm it exists
return found;
}
If a repository method can be called without a tenant, it will eventually be called without a tenant. The signature is the enforcement.
Roles are a separate axis
Tenancy answers which organisation. Roles answer what may this person do inside it. Conflating them produces a system where an owner of one organisation is accidentally privileged in another, which is the worst possible bug in a product companies pay for.
Keeping them separate means every authorisation decision is two questions asked independently: is this record in the caller's organisation, and does the caller's role permit this action on it. Both have to pass. Neither implies the other.
Visibility as domain logic
Opportunities gained states beyond public and private — restricted to particular communities, drafts visible only to the authoring organisation, closed listings visible but not applicable to. It's tempting to express that as flags on the record and let each endpoint interpret them.
That's how you end up with three endpoints disagreeing about whether a draft is visible. Better to resolve visibility in one place and have every read path call it. The rule then exists once, and changing it is one edit rather than a search.
What "automated closure" actually requires
Listings close after their deadline. That sounds like a scheduled job and one boolean, and it isn't quite:
- A deadline can't be set inside a 24-hour window — a listing that closes before anyone realistically sees it is a support ticket, not a feature.
- Closing has to be idempotent. The job will run twice eventually; the second run must be a no-op rather than a second round of notifications.
- Closed doesn't mean invisible. Applicants still need to see what they applied to, so closure changes what actions are permitted rather than removing the record.
Most of the work in any "it just closes automatically" feature is in what closure means for everyone already attached to the thing being closed.
The part I'd insist on again
Tests that run with two organisations, always. A suite with a single fixture tenant will pass happily against code that has no tenant scoping at all — every query returns the only organisation's data, so every assertion holds. Two tenants and one cross-request assertion turns an entire class of bug into a red test.
← All engineering notes