Architecture
Synced from tenant-kit/docs/ARCHITECTURE.md — the repo is canonical.
How tenant-kit is put together, and the rules that keep it small. Written to the same standard as billing-kit's architecture doc: every structural choice here should name the failure it exists to prevent.
1. The module map
flowchart TD
types["types.ts<br/>vocabulary, no runtime code"]
errors["errors.ts<br/>TenancyFailure union"]
tenants["tenants.ts<br/>directory: tenants"]
members["members.ts<br/>directory: memberships"]
resolve["resolve.ts<br/>extract → authorize"]
context["context.ts<br/>TenantScope (ALS)"]
isolation["isolation.ts<br/>scopedExecutor, routedExecutor"]
instance["instance.ts<br/>createTenancy — binds db + clock"]
types --> tenants & members & resolve & context & isolation
errors --> tenants & members & resolve & context & isolation
tenants --> resolve
members --> resolve
tenants & members & resolve & context & isolation --> instance
types.ts and errors.ts have no dependencies and no runtime logic beyond
the error class; everything else depends on them and not on each other,
except resolve.ts, which is exactly the module whose job is to join the
directory to the request. instance.ts is sugar: it binds (db, clock) once
and owns the TenantScope, and every free function stays exported for
callers holding a transaction or composing their own instance.
2. The two-halves rule
Resolution is two functions with a typed wall between them:
| Half | Function | Input | Output | Trust |
|---|---|---|---|---|
| Extraction | Extractor |
RequestLike |
TenantClaim | null |
None. The claim is the caller's assertion. |
| Authorization | authorize |
TenantClaim + UserId |
ResolvedTenant |
The directory and the membership table. |
The wall is the point. Post-incident writeups of multi-tenant breaches share
one sentence: a layer treated a request-supplied tenant reference as
authenticated. Here that sentence cannot be written in the API's types — an
extractor is pure and cannot reach the store; nothing downstream accepts a
TenantClaim; the only producer of ResolvedTenant checks membership on the
way through.
Order of authorization checks: existence → state → membership. Archived
beats not-a-member so an archived tenant's own users are told the truth about
it. What your HTTP layer reveals is then a single decision in one error
mapper — errors.ts documents collapsing not_a_member into 404 for
enumeration resistance.
req.claims is the one input the library must trust as handed to it: claims
from a token your auth layer verified. fromClaim reads them because
service-to-service calls carry their tenant in the token; nothing in this
library can check a signature for you, and the docstrings say so rather than
pretending otherwise.
3. Context: the ambient tenant
TenantScope wraps AsyncLocalStorage<TenantContext>. Three decisions:
- Instance-owned, not module-level. A module-level store is a global; the
house rule (billing-kit §instance) is that two instances coexist in one
process — a test scoping tenants over a rolled-back executor while the app
scopes its own.
createTenancymakes a scope;TenantScopeis exported for anyone composing without the factory. - No
set(). Context enters throughrun(scope, fn)and dies withfn's extent. A mutable ambient tenant is one whose reads cannot be reasoned about; nestingrunis the sanctioned way to impersonate into a second tenant for a support tool, because the inner extent is visible in the code's shape. runaccepts a bareTenantIdas well as aResolvedTenant, because background work — a queue consumer replaying a job that recorded its tenant, a sweep iterating tenants it just listed — legitimately owns its tenant choice without a request or a membership.
require() exists because most code paths in a multi-tenant app are wrong to
reach untenanted, and a thrown no_tenant_context beats each call site
wording its own null check.
4. Isolation
The full decision is ISOLATION.md; the architecture-level summary:
tenancy.db()/scopedExecutormake "whose rows" a property of the connection state, not of every WHERE clause.set_config('tenancy.tenant_id', $1, true)inside a transaction; policies installed bytenancy.protect()compare the row's tenant column totenancy.current_tenant().- FORCE row-level security, always — the table owner is exactly what app connection strings authenticate as, and unforced RLS is theater for them.
- The cast lives on the function side of the policy
(
col = current_tenant()::uuid, nevercol::text = current_tenant()), so typed tenant columns keep their indexes. - The directory tables themselves are not policied: they are what the resolve path reads before any scope exists. Isolation is for the host app's data, and for billing-kit's schema if it is present.
routedExecutoris the whole database-per-tenant offering — memoized routing over a function you write — because provisioning and per-database migrations are operational choices a library would only get wrong on your behalf.
5. API design rules
Inherited from billing-kit, restated because they are checkable in review:
- Configuration as arguments. Every core function takes
(db, …, now). No module state, noprocess.env.instance.tsis binding, not hiding. - Errors are a discriminated union.
TenancyFailureis the contract; messages are derived, never parsed. A caller switching on.messagehas reintroduced the bug this prevents. - Idempotent writes; conflicting retries error by name. Same input →
same result (
createTenant,addMember,archiveTenant,removeMember). Same key, different intent →slug_taken,already_a_member— with the difference spelled out indetail, so the caller doesn't have to query to learn what differed. - Invariants live in the store, under locks. The last-owner rule is a
FOR UPDATEon the owner rows, not a read-then-write; the racing test intest/members.test.tsis the spec. - The database is one narrow interface.
SqlExecutor, structurally identical to billing-kit's, satisfiable by a barepg.Poolin ten lines.transactionmust pin one connection — for RLS this is not a rollback nicety but the isolation mechanism itself.
6. What is deliberately absent
| Absent | Why |
|---|---|
| Users table, sessions, passwords | Auth is the host's. UserId is opaque; membership is checked, identity never. |
| Invitations, email flows | Workflow, not directory. Build on addMember with your own token table. |
| Permissions beyond three roles | Application vocabulary. atLeast is the only comparison the library will ever do. |
| Tenant provisioning hooks / lifecycle events | Your job queue already exists; wrap createTenant. |
| A framework adapter | RequestLike is four optional fields; every framework produces it in two lines. An adapter package would make one framework the favorite. |
| Caching of the directory | A tenant lookup is one indexed read. Cache in front if you must; the library returning stale memberships would be a security decision made for you. |