The tenant registry
What it is
One table listing the tenants that exist, their status, and where they live.
create table tenantlayer_tenants (
tenant_id varchar(64) primary key,
status varchar(16) not null default 'ACTIVE',
region varchar(64),
tenant_group varchar(64),
datasource_ref varchar(128),
metadata jsonb not null default '{}'::jsonb
);Create it through your own migration tool. TenantLayer does not issue DDL against your
database; TenantRegistrySchema.DDL is the statement, for you to commit and review.
It has no row-level security, deliberately
The registry is shared infrastructure that gets consulted during tenant resolution, before any tenant is known. A policy on this table would hide it from the very code whose job is to read it, and the application would not start. Every other table in your schema should have one; this one must not.
Columns, and which are read
| Column | Read by | Meaning |
|---|---|---|
tenant_id | everything | The identifier resolution produces |
status | forEachTenant, membership | ACTIVE or SUSPENDED. Suspended tenants are skipped by iteration. |
datasource_ref | DATABASE_PER_TENANT | Which database this tenant lives in — several tenants may share one |
region, tenant_group | nothing yet | Reserved |
metadata | your code | Anything you want to hang off a tenant |
region and tenant_group are present although nothing reads them. A registry is the
hardest table in the system to change once it holds production rows — adding a column later
means a migration on every deployment plus a backfill nobody has the data for. Both are
nullable and cost a schema no one has to alter twice.
datasource_ref was the same argument until 0.3.0, and is now the routing key for
database-per-tenant: a tenant with a reference uses the database
configured under that name, and a tenant without one uses its own id.
Reading and writing it
@Autowired TenantRegistry registry;
// Everything about one tenant
Optional<TenantRegistration> acme = registry.find("acme");
// Just the ids of the active ones — what forEachTenant iterates
List<String> active = registry.activeTenantIds();Registering a new tenant is an insert like any other:
registry.save(TenantRegistration.of("acme"));
// Or with a shard and some metadata of your own
registry.save(new TenantRegistration(
"globex",
TenantStatus.ACTIVE,
"eu-west-1",
"enterprise",
"shard-a", // datasource_ref
Map.of("plan", "enterprise")));Suspending a tenant is a status change, and iteration stops including them immediately:
registry.find("acme").ifPresent(t -> registry.save(
new TenantRegistration(t.tenantId(), TenantStatus.SUSPENDED,
t.region(), t.group(), t.datasourceRef(), t.metadata())));Suspending does not evict what is already cached. See caching — otherwise a suspended tenant's data stays readable until entries expire, which makes "suspended" mean less than it sounds.
Keeping tenants somewhere else
TenantRegistry is an interface. If your tenants live in another service, or a
configuration file, or a table with a different shape, publish a bean and the
autoconfigured JDBC one backs off:
@Bean
TenantRegistry tenantRegistry(CustomerApi customers) {
return new TenantRegistry() {
@Override
public List<String> activeTenantIds() {
return customers.activeAccountIds();
}
@Override
public Optional<TenantRegistration> find(String tenantId) {
return customers.lookup(tenantId).map(c -> TenantRegistration.of(c.id()));
}
// save / delete / findAll as your source allows
};
}Running work for every tenant
This is the case a request-scoped tenancy layer cannot serve. Done wrong it is not an error: every query runs with no tenant, returns nothing, and the job reports success having done nothing at all.
@Scheduled(cron = "0 0 3 * * *")
void rebuildReports() {
tenantTasks.forEachTenant(tenant -> reportService.rebuild());
}Each iteration runs with that tenant bound, so reportService needs no tenant parameter
and its queries scope themselves. Suspended tenants are skipped.
One tenant's failure does not cancel the rest. A nightly job that aborts on the first
bad tenant leaves everyone after it in the list unprocessed, and which ones those are
depends on alphabetical order. Every tenant is attempted; failures are collected and thrown
at the end as a TenantIterationException that names them, in registry order, so the alert
says which tenants failed rather than that something did.
Map<String, Long> counts = tenantTasks.mapEachTenant(tenant -> orders.count());
// {acme=1, globex=2}For a fixed-tenant job:
tenantTasks.runAs("acme", () -> reportService.rebuild());The scheduler thread is left exactly as it was found — schedulers pool their threads, and a job that leaves a tenant behind hands it to the next job on that thread.
Handling the failures
try {
tenantTasks.forEachTenant(tenant -> reportService.rebuild());
} catch (TenantIterationException e) {
// Named, in registry order — so the alert says which tenants failed, not that some did.
e.failures().forEach((tenant, cause) ->
log.error("nightly rebuild failed for {}", tenant, cause));
}The tenants that succeeded stay succeeded. The exception is thrown after every tenant has been attempted, not on the first failure.