Enterprise
SaaS Substrate
A multi-tenant SaaS foundation lifted out of a product that already ran on it, now carrying three more.
Lifted from something that worked
The foundation came out of a research product that had already shipped. Once it was clear which parts of that codebase had nothing to do with research, those parts moved into a repository of their own: two-tier auth, multi-tenancy, audit, consent and data rights, observability, feature flags and messaging.
53 decision records cover the extraction. The alternative I turned down was starting from a scaffold, which gives you a shape no product has argued with yet.
Where the tenant boundary sits
The rule is that a tenant id is a column, not a middleware check. Every domain table carries one, indexed and non-null, and every query on such a table carries the predicate that scopes it.
Middleware still does something, and it is deliberately small: it opens
one AsyncLocalStorage store per request, and the token validation fills
in the tenant. The repository reads that store directly. Nothing
between the two passes a tenant id along, so no use case grew a parameter
it does not otherwise need and nobody can forget to forward one.
What makes it hold is not a type. It is that the scoping call is visible at every call site, so review is a search. Going around it takes a wrapper that refuses a reason under ten characters at runtime.
System
Clients
Three products
TypeScript
Substrate web app
Next.js · React · TypeScript
Contract
Substrate API
NestJS · TypeScript · Bun
Per request
Tenant context
TypeScript
Why
One store per request, opened by middleware, populated when the token is validated. Everything that reads it reads the same value for the life of that request.
Two-tier identity
CASL · TypeScript
Why
Two populations that share no role table. The operator tier holds everything; the tenant tier holds permissions granted inside one team, and a user row carries no tenant of its own.
Application
Substrate modules
TypeScript
Why
Audit, consent and legal, lifecycle and data rights, entitlements, invitations, onboarding. The parts every product needs and no product wants to write twice.
The open port registry
TypeScript
Why
Three ports, and adding a fourth is a substrate change every fork receives by merging. A closed list is what stops "override this" turning into a second way of writing the whole application.
Adapters
Repositories
Drizzle · TypeScript
Why
Where the boundary is actually enforced. Every query on a table that carries a tenant id includes the scoping predicate, and the reviewer's check is that they can see the call: a query without it is a cross-tenant query.
State
Relational store
PostgreSQL
5/**6 * Returns a SQL predicate scoping a query to the current tenant.7 *8 * Every `.where(...)` clause on a tenant-bearing table MUST include this9 * helper (or be wrapped in `withMasterScope` — see below). This is the10 * load-bearing tenant-isolation seam per ADR 0002.11 *12 * Visibility is the point: code review scans for `tenantScope(` at every13 * repository call site. If you don't see it, the query is cross-tenant.14 *15 * @example16 * db.select().from(users).where(and(tenantScope(ctx, users.tenantId), ...))17 */18export function tenantScope(ctx: TenantContextService, tenantIdColumn: AnyPgColumn): SQL {19 return eq(tenantIdColumn, ctx.tenantId);20}2122/**23 * Audited escape hatch for master-tier reads/writes that legitimately span24 * tenants (operator analytics, support tools, tenant directory listing).25 *26 * The `reason` is captured for the future audit log. Today it's enforced27 * to be non-trivial (≥10 chars); when the audit module is rebuilt on28 * Drizzle, this hook will emit a structured audit event.29 *30 * Never call from tenant-tier code paths. A controller bug here would31 * leak rows — keep this helper out of any module reachable from a32 * tenant-tier route.33 */34export function withMasterScope<T>(reason: string, fn: () => T): T {35 if (typeof reason !== 'string' || reason.trim().length < 10) {36 throw new Error(37 `withMasterScope requires a non-trivial audit reason (>=10 chars). Got: ${JSON.stringify(reason)}.`,38 );39 }40 return fn();41}saas-substrate41a32abapps/api/src/shared/persistence/application/tenant-scope.tsLines 5 to 4137 lines
/**
* Returns a SQL predicate scoping a query to the current tenant.
*
* Every `.where(...)` clause on a tenant-bearing table MUST include this
* helper (or be wrapped in `withMasterScope` — see below). This is the
* load-bearing tenant-isolation seam per ADR 0002.
*
* Visibility is the point: code review scans for `tenantScope(` at every
* repository call site. If you don't see it, the query is cross-tenant.
*
* @example
* db.select().from(users).where(and(tenantScope(ctx, users.tenantId), ...))
*/
export function tenantScope(ctx: TenantContextService, tenantIdColumn: AnyPgColumn): SQL {
return eq(tenantIdColumn, ctx.tenantId);
}
/**
* Audited escape hatch for master-tier reads/writes that legitimately span
* tenants (operator analytics, support tools, tenant directory listing).
*
* The `reason` is captured for the future audit log. Today it's enforced
* to be non-trivial (≥10 chars); when the audit module is rebuilt on
* Drizzle, this hook will emit a structured audit event.
*
* Never call from tenant-tier code paths. A controller bug here would
* leak rows — keep this helper out of any module reachable from a
* tenant-tier route.
*/
export function withMasterScope<T>(reason: string, fn: () => T): T {
if (typeof reason !== 'string' || reason.trim().length < 10) {
throw new Error(
`withMasterScope requires a non-trivial audit reason (>=10 chars). Got: ${JSON.stringify(reason)}.`,
);
}
return fn();
}- Not through the application. The tenant id is opened into an AsyncLocalStorage store before the guards run and read again down in the repository, so no use case has to accept it as an argument and no developer has to remember to pass it on.
What a fork may change
A product applies its identity additively: environment variables, its
own files, and a small merge=ours set. It never edits a substrate file.
That only survives if the substrate opens the right seams, so the ports a
fork may replace are a closed registry of three.
The spend meter is the clearest. The substrate binds it to a no-op, which is the correct binding: the substrate makes no cost-bearing calls. A product that does spend money binds the real adapter and touches no substrate module doing it.
Contribution
- Commits authored
- 962
- Decisions recorded
- 53
- Built on it
- 3
12 of 14 weeks active · 11 May 2026 – 16 Aug 2026
Longest run · 9 weeks · May – Jul
One repository. Of the four, this is the only one where a fix may be written.
Measured 2026-09-08
Where a fix gets written
Fixes are authored here and merged down, never inside a fork. A repair made in one fork is invisible to the other two and arrives as a conflict on the next merge. The workspace scope stays identical across all four repositories for the same reason.
Three products stand on it. An exam-coaching platform for İleri Akademi, an LGS and YKS consultancy, is client work with 1,048 commits of its own, and AutoAds has its own page here.