Repository Scoping
How BaseRepository injects the school_id filter and stamp on every read and write — fail-closed — and how @TenantScoped opts an entity in.
This is the payoff of the tenant context: BaseRepository reads it on every operation and either scopes the query, skips scoping, or throws. The whole point is that a developer can't forget — the engine does it, transparently.
resolveTenant
BaseRepository is the class every concrete repository extends, and it consults the context on every operation through one private method, resolveTenant(). That method returns one of three resolutions: inject (scope it), bypass (don't), or fail (throw).
private resolveTenant(): TenantResolution {
const meta = getTenantMeta(this.entityClass);
if (!meta) return { mode: 'bypass' }; // entity isn't tenant-scoped
const ctx = getTenantContext();
if (!ctx) return { mode: 'fail' }; // no context at all
if (ctx.bypass) return { mode: 'bypass' }; // operator / runUnscoped
if (ctx.schoolId == null) return { mode: 'fail' }; // fail-closed default
return { mode: 'inject', schoolId: ctx.schoolId, tenantKey: meta.tenantKey };
}Read it top to bottom: an entity that isn't tenant-scoped is never touched; a tenant-scoped entity with no usable context fails; otherwise the school id gets injected. The fail mode is converted to a thrown error at each call site via failClosed(), which raises TenantContextMissingError.
Scoping reads
On reads, the resolution merges a { schoolId } predicate into your where clause. Whatever you searched for, the engine adds AND school_id = :sid:
private scopeWhere(where?) {
const t = this.resolveTenant();
if (t.mode === 'fail') this.failClosed();
if (t.mode === 'bypass') return where;
return this.mergeTenantWhere(where, t.schoolId, t.tenantKey); // adds { schoolId }
}The engine injects only the school predicate. The academic year is never auto-injected — year-scoped entities carry campusAcademicYearId as explicit, required request data instead. See Academic-Year Scoping.
Stamping writes
On writes, it stamps the schoolId onto the payload — and if the payload already carries a different school, it throws CrossTenantWriteError. You cannot write a row into someone else's tenant, even by passing the id explicitly:
private stampTenant<D extends Record<string, unknown>>(data: D): D {
const t = this.resolveTenant();
if (t.mode === 'fail') this.failClosed();
if (t.mode === 'bypass') return data;
this.assertSameTenant(data, t.schoolId, t.tenantKey); // throws on mismatch
(data as Record<string, unknown>)[t.tenantKey] = t.schoolId;
return data;
}Query builder guard
The query-builder path needs an extra guard. TypeORM's .where() resets the accumulated where-clauses, which would silently drop the tenant predicate the engine just added. So createQueryBuilder reroutes .where to .andWhere on tenant-injected builders — isolation can't be clobbered by a later .where() call:
if (t.mode === 'inject') {
qb.andWhere(`${qb.alias}.${t.tenantKey} = :__tenantSchoolId`, {
__tenantSchoolId: t.schoolId,
});
// .where() would reset wheres and drop the tenant predicate — reroute it:
const boundAndWhere = qb.andWhere.bind(qb);
qb.where = ((where, params?) => boundAndWhere(where, params)) as typeof qb.where;
}The result, when both scoping layers are active, is SQL like this — tenant isolation from this engine, campus scope from CASL, plus whatever your service asked for:
WHERE school_id = :sid -- tenant isolation (this engine)
AND (campus_id = :c OR campus_id IS NULL) -- CASL campus scope (separate layer)
AND (… your service filters …)@TenantScoped
How does the repository know TransactionEntity is tenant-owned but SchoolEntity isn't? A decorator. @TenantScoped() registers the entity class in a central Map alongside its tenant key (which property holds the school id, defaulting to schoolId). resolveTenant() just looks the class up in that map.
export function TenantScoped(options?: { tenantKey?: string }): ClassDecorator {
return (target: Function) => {
TENANT_REGISTRY.set(target, { tenantKey: options?.tenantKey ?? 'schoolId' });
};
}In practice you add two things to the entity — the decorator and the school_id column — and nothing to the repository:
@Entity('transaction')
@TenantScoped()
export class TransactionEntity extends DatabaseEntity {
@Column({ name: 'school_id', type: 'uuid', nullable: true })
schoolId?: string;
// ...
}The repository extends BaseRepository exactly as it would without scoping — the engine is entirely transparent to it:
export class TransactionRepository extends BaseRepository<TransactionEntity> {
constructor() { super(TransactionEntity); }
}Deliberately global entities
Some entities are deliberately global — undecorated, never scoped — because scoping them would be circular or wrong:
| Entity | Why it is NOT tenant-scoped |
|---|---|
SchoolEntity | It is the tenant root — scoping by its own id is circular. |
platform_admin_profile | Operators are school-less; must be readable without a tenant. |
refresh_tokens, address | Cross-cutting, not owned by any one school. |
permission registry, role_x_permission | Global catalog; only role assignments are per-school. |
To find the current authoritative list, grep -rl "@TenantScoped" apps/server/src.
Where to go next
Escape Hatches
When there's no request — jobs, seeders, operators — establish a tenant yourself with runUnscoped or withSchool.
Multi-Tenancy Overview
Back to the start: the School as tenant, the AsyncLocalStorage context, and how it's set per request.
The Database
BaseRepository in full, plus entity and migration conventions.
Add an Entity & Migration
A step-by-step recipe for a new tenant-scoped entity, including the school_id column.