tenantlayer.io

Migrations

Once there is more than one schema, flyway migrate stops being a single command. That is the point at which migrations become an operational problem rather than a build step.

Turn off Boot's automatic migration first

This is the first thing to do, and skipping it is confusing rather than obviously wrong.

Simply having flyway-core on the classpath makes Spring Boot auto-configure a single-schema migration that runs at every application start, against whatever the connection's default schema is. Under schema-per-tenant that is not where your tables live, and under a shared schema it will race with the runner below.

spring.flyway.enabled=false

Then run migrations explicitly.

Running them

@Autowired TenantMigrationRunner migrations;
 
MigrationOutcome outcome = migrations.migrateAll();   // every active tenant
migrations.migrate("acme");                           // just one
tenantlayer.migration.locations=classpath:db/tenant-migration

A worked migration

Tenant migrations live apart from shared ones, because they run a different number of times:

src/main/resources/db/
├── shared/                    # run once — the registry, reference data
│   └── V1__registry.sql
└── tenant-migration/          # run per tenant under schema- or database-per-tenant
    ├── V1__orders.sql
    └── V2__add_order_status.sql
-- V1__orders.sql — no schema qualifier. The runner sets the schema or picks the database.
create table orders (
    id           bigserial primary key,
    tenant_id    varchar(64)  not null default current_setting('tenantlayer.tenant', true),
    customer     varchar(255) not null,
    amount_cents bigint       not null
);
 
create index idx_orders_tenant on orders (tenant_id);
 
alter table orders enable row level security;
alter table orders force row level security;
 
create policy tenant_isolation on orders
    using (tenant_id = nullif(current_setting('tenantlayer.tenant', true), ''));

Leave the tables unqualified. Under schema-per-tenant the runner sets search_path and createSchemas, and under database-per-tenant it points Flyway at that tenant's own datasource — qualifying a table with a schema name defeats both.

Running them on deploy

@Component
class MigrateOnStartup implements ApplicationRunner {
 
    private final TenantMigrationRunner migrations;
 
    MigrateOnStartup(TenantMigrationRunner migrations) {
        this.migrations = migrations;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        MigrationOutcome outcome = migrations.migrateAll();
        log.info("migrated {} tenants", outcome.migrated().size());
    }
}

And for a single tenant, which is what onboarding needs:

migrations.migrate("acme");

It asks the strategy rather than assuming

Under a shared schema — which is what row-level security uses — there is one set of tables and one migration history. migrateAll() runs once, not once per tenant. Looping would replay the same migrations against the same tables, and Flyway would refuse.

Under schema-per-tenant it runs once per active tenant, against that tenant's schema, creating the schema if it does not exist so a newly provisioned tenant needs no out-of-band setup.

That decision comes from TenantConnectionStrategy.schemaFor(tenantId) rather than from configuration, so database-per-tenant slots in later without this being rewritten.

Migrations bypass the tenant-aware DataSource

Deliberately. That wrapper sets the tenant, or the search_path, on every checkout from whatever is in the context — and during a migration the context is empty or belongs to whichever tenant happened to be bound. Flyway is given the schema explicitly instead, on the underlying pool.

You therefore do not need a tenant bound to run migrations, and binding one changes nothing.

One tenant failing does not stop the others

A run that aborts on the first bad tenant leaves everyone after it unmigrated, and which ones those are depends on alphabetical order. Every tenant is attempted; failures are collected and thrown at the end as a TenantMigrationException naming them, in registry order.

try {
    migrations.migrateAll();
} catch (TenantMigrationException e) {
    e.outcome().failedTenants();          // ["globex"]
    e.outcome().migrated();               // {"acme": 2}
}

Tenants can therefore be at different schema versions. That is a real operational state, not an error — and it is why the exception names them rather than reporting a count.

Adopting Flyway into a database that already exists

baselineOnMigrate marks the current state as version 1 and skips migrations at or below it. That is usually what you want when adopting Flyway into an existing schema — and it means V1__…sql will not run, which surprises people whose V1 creates the tables.

tenantlayer.migration.baseline-on-migrate=true

If you need V1 to run against an already-populated schema, baseline at version 0 instead, or number your first real migration V2.

Configuration

PropertyDefaultMeaning
tenantlayer.migration.enabledtrueExpose the runner
tenantlayer.migration.locationsclasspath:db/tenant-migrationWhere tenant migrations live
tenantlayer.migration.baseline-on-migratefalseBaseline an existing schema at version 1

Keep tenant migrations out of classpath:db/migration — that is Boot's default location, and anything there is picked up by the automatic single-schema migration you turned off above, should it ever be turned back on.

Database-per-tenant

Each tenant has its own database, so migrations run once per tenant against that tenant's own datasource rather than once against a shared one. The runner asks the strategy (migratesPerTenant()) rather than inferring it from the schema — under DATABASE_PER_TENANT every tenant uses the same schema name, so deciding from the schema alone would migrate one database and silently leave every other tenant on an old version.

A tenant with no configured database is not skipped quietly: it fails, and the failure names the tenant.