Multi-tenancy
A multi-tenant TesseraQL application serves several customer organizations — tenants — from one
deployment, keeping each tenant’s data isolated from every other’s. Tenancy is off by default:
an application is single-tenant until tenancy.enabled: true is set, and nothing on this page
applies before then. Once enabled, every request resolves a tenant before its SQL runs, and that
tenant drives either a row predicate (shared schema) or the choice of connection pool
(schema/database per tenant).
Tenancy answers which organization’s data a request may touch. What a caller may see within their tenant — their department, their own records — is the job of organizational data scoping, which deliberately mirrors the tenant-predicate mechanism one level deeper.
Isolation modes
Section titled “Isolation modes”The tenancy block lives in config/application.yml alongside the database coordinates. Three
modes exist; shared-schema is the default when tenancy is enabled.
Shared schema
Section titled “Shared schema”All tenants share the main datasource and every tenant-owned table carries a tenant column.
Isolation is a predicate the route author writes into the SQL:
tenancy: enabled: true mode: shared-schema required: true # reject requests without a resolvable tenant (the default) resolver: type: header source: X-Tenant-Idsources: main: sql: file: list.sql mode: query params: tenant_id: tenant.idselect id, namefrom itemswhere tenant_id = /* tenant_id */ 'dummy'order by idBecause the predicate is hand-written, lint watches for its absence: see
TQL-TENANT-3001 below.
Schema per tenant and database per tenant
Section titled “Schema per tenant and database per tenant”Each tenant gets its own connection pool, declared under tenancy.datasources — one block per
tenant id, same keys as any pool (jdbcUrl required, username/password optional and secrets
by reference). The two modes share one mechanism and differ only in where the URLs point: for
schema-per-tenant, each pool targets a different schema of the same server (for example a
currentSchema parameter on a PostgreSQL URL); for database-per-tenant, entirely separate
databases:
tenancy: enabled: true mode: database-per-tenant resolver: type: header source: X-Tenant-Id datasources: acme: jdbcUrl: jdbc:postgresql://db-acme:5432/app username: app password: ${secret.env.ACME_DB_PASSWORD} globex: jdbcUrl: jdbc:postgresql://db-globex:5432/app username: app password: ${secret.env.GLOBEX_DB_PASSWORD}In these modes the application tables need no tenant column — isolation is structural. The
resolved tenant’s pool replaces main for that request’s SQL; a resolved tenant with no
configured pool is rejected with 403 (TQL-TENANT-4031) rather than silently falling back to a
shared pool. Tenant routing replaces only main: an explicit non-main datasource: on a
route is deployment-shared infrastructure and is never tenant-routed
(multi-datasource routes).
Tenant resolution
Section titled “Tenant resolution”Resolution runs once per request, after authentication, and publishes the tenant into the execution context. Two resolver types exist:
header(the default) — the tenant id is read from a request header,X-Tenant-Idunlessresolver.sourcenames another. Typically a gateway in front of the application sets and trusts this header.claim— the tenant id is read from the authenticated principal, following the dotted path inresolver.source(defaulttenantId). The principal’s tenant is populated by the authentication mechanism — a JWTtenantClaim, an OIDC claim mapping, or an API key / mTLS client whose tenant is bound to the credential so a caller cannot escalate across tenants (authentication).
With required: true (the default when tenancy is enabled), a request with no resolvable tenant
is rejected with 400 (TQL-TENANT-4001) — deny-by-default. The resolved tenant is also recorded
on the route’s telemetry span.
Resolution by subdomain is the third resolver: resolver.type: host with
resolver.source: "{tenant}.example.com" reads the request’s Host header — the suffix must
match exactly, the tenant is the single remaining label, and only slug labels ([a-z0-9-]+)
resolve, so a crafted Host header can never smuggle an arbitrary tenant id. Anything else
resolves nothing and, when required, rejects with TQL-TENANT-4001.
The tenant in SQL
Section titled “The tenant in SQL”The resolved tenant joins the 2-way SQL bind sources as tenant: tenant.id
is the tenant identifier, and tenant.attributes.<name> exposes any additional tenant metadata.
On per-tenant jobs the same tenant.id bind is available even though
there is no request.
Lint and errors
Section titled “Lint and errors”| Code | Meaning |
|---|---|
TQL-TENANT-3001 |
warning — a shared-schema SQL route neither binds tenant.* nor mentions a tenant column in its SQL; the query would leak rows across tenants. Bind tenant.id or filter by a tenant column. |
TQL-TENANT-4001 |
400 — no tenant could be resolved for the request (with required: true). |
TQL-TENANT-4031 |
403 — the resolved tenant has no configured datasource in a per-tenant mode. |
TQL-TENANT-5005 |
the tenant registry query failed. |
Data scoping reuses this exact shape — a parameterized predicate at an author-chosen site, backed by a lint — to confine rows within the tenant by the principal’s organizational reach. Tenancy decides whose database or rows a request touches at all; scoping decides which of those rows this particular caller may see.
The tenant directory
Section titled “The tenant directory”Anything that fans out over all tenants — per-tenant jobs, or listing tenants at all in shared schema — needs a directory of tenant ids. It is resolved in precedence order:
- a static
tenancy.tenantslist in config, - a
tenancy.registry.sqlquery run against themaindatasource (first column of each row), bounded bytesseraql.sql.timeoutSecondslike every other declared statement, - the keys of
tenancy.datasources.
Shared-schema deployments have no pools to enumerate, so they use the list or the registry query:
tenancy: enabled: true mode: shared-schema registry: sql: select tenant_id from tenants order by tenant_idPer-tenant infrastructure
Section titled “Per-tenant infrastructure”- Migrations. The app’s
db/migrationset runs againstmainand, in the per-tenant modes, against every configured tenant pool, each with its own history — see application layout. Tenant pools are read from config at startup, so onboarding a tenant in a per-tenant mode is a config change. - Jobs.
perTenant: trueruns a job once per tenant in the directory, each run on that tenant’s own datasource (or the shared one, scoped by thetenant.idbind) as a separate execution record — see jobs. - Framework tables. The managed
tql_*tables (preferences, shortcuts, inbox, workflow, delegations, route audit) live on themaindatasource in every mode and discriminate by atenant_idcolumn, as do the default identity store’s users and groups. Notification outbox writes from per-tenant job runs likewise land onmain, where the dispatcher reads them.
What tenancy is not
Section titled “What tenancy is not”- Not per-tenant: named datasources, templates, messages, feature flags, and the rest of the application configuration are deployment-wide. One tenant cannot get different behavior, only different data.
- Not self-service onboarding: per-tenant pools come from configuration; there is no runtime API to create a tenant’s database.
- Not authorization: tenancy never decides what a caller may do inside their tenant — that is route policy plus data scoping.
- Not automatic SQL rewriting: in shared schema the tenant predicate is written by the author and checked by lint, never injected behind the query.
Where to go next
Section titled “Where to go next”- Organizational data scoping — row-level restriction within a tenant
- Application layout — where migrations live and how they run per tenant pool
- Jobs — per-tenant batch fan-out
- Deployment — pool tuning, audit trail, and operating the runtime
- data-scoping.md — row-level scoping within a tenant.
- deployment.md — running one deployment for many tenants.