tenantlayer.io

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

ColumnRead byMeaning
tenant_ideverythingThe identifier resolution produces
statusforEachTenant, membershipACTIVE or SUSPENDED. Suspended tenants are skipped by iteration.
datasource_refDATABASE_PER_TENANTWhich database this tenant lives in — several tenants may share one
region, tenant_groupnothing yetReserved
metadatayour codeAnything 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.