Kora Database Migration — Flyway and Liquibase
Kora sub-skill — obey the kora-v2 meta rules on every task: R0 ground the workspace on Kora 2.0 refs before starting (framework source at tag
2.0.0.RC1+kora-examplesatmigration/2.0;kora-docsis 1.x only) · R1 read this sub-skill before writing code · R2 Kora 2.0 APIs only — no Spring/Micronaut/Quarkus, no Kora 1.x APIs, no invented annotations or config keys · R3 journal any incorrect Kora usage. Add comments/Javadoc only if asked.
| Flyway | Liquibase | |
|---|---|---|
| Artifact | io.koraframework:database-flyway |
io.koraframework:database-liquibase |
| Module | io.koraframework.database.flyway.FlywayJdbcDatabaseModule |
io.koraframework.database.liquibase.LiquibaseJdbcDatabaseModule |
| Config type | FlywayConfig |
LiquibaseConfig |
| Config section | flyway |
liquibase |
| Interceptor | FlywayJdbcDatabaseInterceptor |
LiquibaseJdbcDatabaseInterceptor |
| Third-party version | flyway-core 13.3.0 |
liquibase-core 5.0.3 |
| Extra artifact needed | Yes — a per-database dialect (org.flywaydb:flyway-database-postgresql) |
No — liquibase-core bundles the standard databases |
| Prerequisite | io.koraframework:database-jdbc + JdbcDatabaseModule on @KoraApp |
same |
Both modules are optional add-ons to the JDBC module. Neither has a Cassandra counterpart — migrations attach to the JDBC datasource only.
1. How migrations run — the interceptor, not a lifecycle hook
FlywayJdbcDatabaseModule contributes exactly one thing to the graph: a
FlywayJdbcDatabaseInterceptor implements GraphInterceptor<JdbcDataSource>. At compile time the
@KoraApp processor matches every GraphInterceptor<T> component against the nodes of type T and
wires it into the generated graph. At runtime the graph calls afterInit(dataSource) after
JdbcDataSource.init() has opened and validated the pool, and before any component that depends on
the datasource is constructed. Liquibase works identically.
Three consequences that drive every decision below:
- Migrations block startup. They run inside graph initialization, so the service is not listening and not ready until the migration finishes. A migration failure surfaces as a graph initialization error and the application exits.
- Every replica migrates. The interceptor is attached per graph, so a horizontally scaled deployment runs it once per pod on every rollout — see §6.
- The interceptor needs pool capacity. Flyway takes two connections (migration + schema
history lock); Liquibase takes one.
jdbc.maxPoolSize = 1deadlocks an in-app Flyway migration at startup with no error message — the framework's own Flyway test pins the pool at2for exactly this reason.
Nothing else is required: no @Root, no ordering config, no explicit module method. Plugging the
module in is the whole wiring.
2. Quick start — Flyway
1. Dependencies. Kora artifacts inherit their version from io.koraframework:kora-bom. The
dialect artifact does not — kora-bom only constrains Kora's own modules, so it carries an
explicit version.
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion") // koraVersion=2.0.0.RC1
annotationProcessor "io.koraframework:annotation-processors"
implementation "io.koraframework:database-jdbc"
implementation "io.koraframework:database-flyway"
// Since Flyway 10 per-database support lives in separate artifacts and
// database-flyway ships only flyway-core: without this the app dies at
// startup with "FlywayException: Unsupported Database: PostgreSQL 16.2".
implementation "org.flywaydb:flyway-database-postgresql:13.3.0"
runtimeOnly "org.postgresql:postgresql:42.7.13"
}
Kotlin — same coordinates, ksp("io.koraframework:symbol-processors") instead of the annotation
processor:
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}")
implementation("io.koraframework:database-jdbc")
implementation("io.koraframework:database-flyway")
implementation("org.flywaydb:flyway-database-postgresql:13.3.0")
runtimeOnly("org.postgresql:postgresql:42.7.13")
}
Pick the dialect artifact for your database (flyway-database-postgresql, flyway-mysql,
flyway-database-oracle, …) and pin it to the same version as the flyway-core that
database-flyway brings in — 13.3.0 for Kora 2.0.0.RC1. Verify with
./gradlew dependencies --configuration runtimeClasspath | grep flyway.
2. Plug both modules into @KoraApp. FlywayJdbcDatabaseModule supplies only the interceptor;
the datasource still comes from JdbcDatabaseModule.
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.database.flyway.FlywayJdbcDatabaseModule;
import io.koraframework.database.jdbc.JdbcDatabaseModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JdbcDatabaseModule,
FlywayJdbcDatabaseModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
@KoraApp
interface Application :
HoconConfigModule,
JdbcDatabaseModule,
FlywayJdbcDatabaseModule
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
3. Migration script at src/main/resources/db/migration/V1__setup_tables.sql:
CREATE TABLE IF NOT EXISTS categories (
id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY,
name VARCHAR NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS pets (
id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY,
name VARCHAR NOT NULL,
status SMALLINT NOT NULL,
category_id BIGINT NOT NULL REFERENCES categories(id),
PRIMARY KEY (id)
);
4. Configure application.conf. The JDBC section is jdbc in Kora 2.0 — it was db in
1.x, and a leftover db { } block fails startup with
ConfigValueException: Config expected value, but got null at path: 'ROOT.jdbc.username'.
jdbc {
jdbcUrl = ${POSTGRES_JDBC_URL}
username = ${POSTGRES_USER}
password = ${POSTGRES_PASS}
poolName = "my-service"
maxPoolSize = 10 // must be >= 2 while in-app Flyway migration runs
}
flyway {
locations = "db/migration"
}
Every FlywayConfig and LiquibaseConfig key has a default and both are declared @ConfigMapper
with mapNullAsEmptyObject = true, so the section itself is optional — a module with no
flyway { } block migrates db/migration with defaults.
3. Flyway configuration
The complete FlywayConfig surface — these seven keys are all that exists:
flyway {
enabled = true // run on startup; false skips migration entirely
mode = MIGRATE // MIGRATE | REPAIR | CLEAN_MIGRATE (exact case)
locations = ["db/migration"] // classpath dirs holding V*.sql
executeInTransaction = true // wrap each migration in a transaction
validateOnMigrate = true // verify checksums of applied scripts first
mixed = false // allow transactional + non-transactional in one run
configurationProperties { } // raw flyway.* properties, flat quoted keys
}
modeis new in Kora 2.0 and is matched exactly, uppercase.mode = migratefails withUnknown enum value: migrate when expected one of [MIGRATE, REPAIR, CLEAN_MIGRATE].CLEAN_MIGRATEdrops the schema before migrating — a local/test convenience, never production.locationsaccepts either a HOCON array or a single comma-separated string; the migrated guide apps use the string form (locations = "db/migration").mixed = trueis for statements that cannot run in a transaction (CREATE INDEX CONCURRENTLYon PostgreSQL, and equivalents on Aurora PostgreSQL, SQL Server, SQLite). The whole run then executes without a transaction.configurationPropertiesmaps toFlyway.configure().configuration(Map), whose keys are Flyway's ownflyway.-prefixed property names. Kora maps it as a flatMap<String, String>, so the dotted key must be quoted — unquoted, HOCON nests it and the config mapper throws:flyway { configurationProperties { "flyway.defaultSchema" = "public" "flyway.baselineOnMigrate" = "true" } }
Full details — script naming, multiple locations, checksum recovery, testing — references/flyway-migration-reference.md.
4. Liquibase configuration
LiquibaseConfig exposes a single key:
liquibase {
changelog = "db/changelog/db.changelog-master.xml" // default
}
Point it at a formatted-SQL master to keep native SQL:
liquibase {
changelog = "db/changelog/db.changelog-master.sql"
}
Two asymmetries against Flyway that matter:
- There is no
liquibase.enabledkey.LiquibaseJdbcDatabaseInterceptor.afterInitrunsupdate()unconditionally. To stop in-app migration you must removeLiquibaseJdbcDatabaseModulefrom the@KoraAppinterface — there is no config switch. - No dialect artifact is needed.
org.liquibase:liquibase-core5.0.3 aggregatesliquibase-standard, which carries the mainstream database implementations; the framework's own Liquibase test migrates a real PostgreSQL withliquibase-corealone.
Formatted-SQL changelogs need a header line and a --changeset per change; rollback is declared
with --rollback:
--liquibase formatted sql
--changeset developer:1
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE
);
--rollback DROP TABLE users;
Changeset directives, contexts/labels, includes and rollback patterns — references/liquibase-migration-reference.md.
5. Choosing between them
| Flyway | Liquibase | |
|---|---|---|
| Script format | versioned SQL (V<n>__<name>.sql) |
changelog (XML default; formatted SQL, YAML, JSON) |
| Default location | db/migration |
db/changelog/db.changelog-master.xml |
| Rollback | not in open-source Flyway | declared in the changelog, applied out-of-process |
| Disable in-app | flyway.enabled = false |
remove the module from @KoraApp |
| Extra dependency | dialect artifact required | none |
| Pick when | default choice; SQL-only, simplest | you need declarative rollback or already own a changelog |
Plug in exactly one. Both interceptors attach to the same JdbcDataSource node and would both
manage the same schema.
6. Out-of-process migrations (recommended for scaled services)
§1 established the mechanism: the interceptor runs during graph initialization, once per replica,
on every rollout. Both tools serialize concurrent runs with a database lock (Flyway around
flyway_schema_history, Liquibase via DATABASECHANGELOGLOCK), so replicas do not corrupt each
other — but every pod still waits for that lock, a run that dies mid-migration can leave it held,
and a bad migration takes down the whole rollout rather than one job. For anything beyond a single
instance, run migrations once, before the app starts.
Local development — Flyway Gradle plugin. The canonical kora-java-crud example takes this
route: it wires only JdbcDatabaseModule, with no database-flyway dependency at all, and migrates
through the plugin.
buildscript {
dependencies {
// the plugin runs in the Gradle JVM, so the dialect goes on the buildscript classpath
classpath "org.flywaydb:flyway-database-postgresql:13.3.0"
}
}
plugins {
id "org.flywaydb.flyway" version "13.3.0"
}
flyway {
url = "jdbc:postgresql://localhost:5432/mydb"
user = "postgres"
password = "postgres"
locations = ["classpath:db/migration"]
baselineOnMigrate = true
}
./gradlew flywayMigrate
Keep the plugin and its dialect artifact on the same version. (kora-java-crud currently pairs
plugin 13.0.0 with dialect 12.10.0; treat that as example drift, not a pattern to copy.)
Production — Kubernetes Job run once before the rollout, with the app keeping
flyway.enabled = false (Flyway) or no migration module at all (Liquibase):
apiVersion: batch/v1
kind: Job
metadata:
name: db-migration
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: flyway
image: flyway/flyway:13.3.0
args: ["migrate"]
env:
- name: FLYWAY_URL
valueFrom: { configMapKeyRef: { name: db-config, key: url } }
The Liquibase equivalent is liquibase/liquibase:5.0.3 with
args: ["--changelog-file=db/changelog/db.changelog-master.xml", "update"], or the
org.liquibase.gradle plugin (3.1.0) locally.
CI — run the same command as a pipeline step against the target database before deploying.
7. Common pitfalls
| Symptom | Cause / fix |
|---|---|
FlywayException: Unsupported Database: PostgreSQL 16.2 at startup |
database-flyway ships only flyway-core. Add org.flywaydb:flyway-database-postgresql (or your database's artifact) at the same version as flyway-core. |
ConfigValueException: … got null at path: 'ROOT.jdbc.username' |
The datasource section is jdbc in 2.0, not db. Applies to HOCON inside KoraConfigModification.ofString("""…""") too. |
| Startup hangs with no error while Flyway migrates | jdbc.maxPoolSize is 1. Flyway needs two connections; raise it to at least 2. |
Unknown enum value: migrate when expected one of [MIGRATE, REPAIR, CLEAN_MIGRATE] |
flyway.mode is exact-match uppercase. |
Config expected value with type 'StringValue' under configurationProperties |
An unquoted dotted key made HOCON nest an object. Quote the whole key: "flyway.defaultSchema" = "public". |
| Nothing migrates although the module is present | flyway.enabled = false, or locations points at a classpath dir with no V*.sql. Liquibase has no enabled key — check the changelog path instead. |
Validation failed. Checksum changed |
An applied script was edited. Never edit applied migrations; add a new version and repair the history out-of-process. |
CREATE INDEX CONCURRENTLY fails inside a transaction |
flyway.mixed = true — the whole run becomes non-transactional. |
| All replicas migrate on every rollout | Expected: the interceptor is per graph. Move to a Job/CI step and set flyway.enabled = false. |
| Liquibase formatted SQL ignored | First line must be --liquibase formatted sql; each change needs --changeset <author>:<id>. |
ChangeLogParseException: … requires a valid Liquibase license key |
--include / --includeAll in a formatted-SQL changelog are Pro-only in Liquibase 5. Split with an XML master that <include>s the .sql children. |
Both modules on @KoraApp |
Keep exactly one of FlywayJdbcDatabaseModule / LiquibaseJdbcDatabaseModule. |
package ru.tinkoff.kora.database.flyway does not exist |
1.x coordinates. Group is io.koraframework, BOM is io.koraframework:kora-bom — kora-parent does not exist in 2.0. |
References & assets
| File | Purpose |
|---|---|
| references/flyway-migration-reference.md | Full FlywayConfig, dialect artifacts, script naming, mixed, configurationProperties, checksum recovery, testing |
| references/liquibase-migration-reference.md | LiquibaseConfig, formatted-SQL changesets, rollback, contexts/labels, includes, testing |
| assets/V1__initial_schema.sql.template | Flyway initial migration starter (db/migration/V1__*.sql) |
| assets/db.changelog-master.sql.template | Liquibase formatted-SQL master changelog |
| assets/002-orders-table.sql.template | Liquibase follow-up changeset, <include>d from an XML master |
Related skills
- kora-database-jdbc —
JdbcDatabaseModule, thejdbcsection, repositories (prerequisite) - kora-config-hocon — how the
flyway/liquibasesections are mapped - kora-config-yaml — the YAML form of the same sections
- kora-testing-junit-java · kora-testing-junit-kotlin —
@KoraAppTest+ Testcontainers with migrations - kora-project-dependencies — BOM, module coordinates, externally-versioned dependencies
- kora-di-runtime —
GraphInterceptorand graph lifecycle
Source of truth
Version-aligned authorities for Kora 2.0. The published documentation site describes Kora 1.x
(ru.tinkoff.kora) on every branch, including pages served under a /v2/ path — never resolve a
2.0 migration question from it.
- Framework source, tag
2.0.0.RC1: database-flyway · database-liquibase · database-jdbc - Migrated guide apps, branch
migration/2.0(in-app Flyway): kora-java-guide-database-jdbc-advanced-app · kora-kotlin-guide-database-jdbc-advanced-app - Migrated examples, branch
migration/2.0: kora-java-crud (Gradle-plugin strategy) · kora-java-petclinic (in-app module) - Third-party: Flyway 13 docs · Liquibase docs