Flyway on Spring Boot 4 and Java 25: a silent failure, and what it taught me about modular auto-config
The goal: a Flyway showcase on a deliberately bleeding-edge stack
I wanted a small, honest playground for Flyway — database schema migrations — running inside a Spring Boot app against a real Postgres, with as little ceremony as possible. So I let Spring Initializr hand me a project on the newest stack I could get: Spring Boot 4.1.0 on Java 25, Spring Data JDBC (not JPA), and the spring-boot-docker-compose integration to manage Postgres. What looked like a 10-minute exercise turned into a genuinely instructive afternoon, mostly because of one silent failure that I think a lot of people will hit as they move to Boot 4. Here's the whole arc.
The setup: less config than you'd expect
The starter project barely needs an application.properties. The trick is the spring-boot-docker-compose dependency: when it's on the classpath, simply running the app detects compose.yaml, starts the container, and auto-configures the datasource from it. No hand-written spring.datasource.*. My compose file, with one tweak — I pinned a fixed host port so I could point DataGrip at it:
services:
postgres:
image: 'postgres:latest'
environment:
- 'POSTGRES_DB=mydatabase'
- 'POSTGRES_USER=myuser'
- 'POSTGRES_PASSWORD=secret'
ports:
- '5436:5432'
One detail worth internalizing: because this is Spring Data JDBC, not JPA/Hibernate, there is no ddl-auto schema generation. Flyway is the sole owner of the schema. That's exactly the clean story you want for a migrations demo — nothing else is quietly creating tables behind your back.
Adding Flyway — and a wall of silence
I did the obvious thing: added org.flywaydb:flyway-core plus flyway-database-postgresql, dropped two migrations in src/main/resources/db/migration/, wrote a Book record + repository, and a tiny CommandLineRunner to read the rows back on startup. Ran it. The container came up healthy, Hikari connected fine, and then:
org.springframework.jdbc.BadSqlGrammarException: bad SQL grammar [SELECT COUNT(*) FROM "book"]
Caused by: org.postgresql.util.PSQLException: ERROR: relation "book" does not exist
The table didn't exist. But here's the tell that cracked the case: there was not a single Flyway log line. No Flyway Community Edition 12.4.0 by Redgate banner, no Migrating schema "public" to version "1". When Flyway is wired up it always prints that banner, even with zero migrations. Total silence means its auto-configuration never ran.
Two red herrings
My first guess was the classic IDE trap: I edited pom.xml from outside, so maybe IntelliJ hadn't re-imported Maven and Flyway wasn't on the run classpath. Plausible — the IDE builds its run classpath from its last Maven import, not the file on disk. But the full java command line in the run console disproved it: both flyway-core-12.4.0.jar and flyway-database-postgresql-12.4.0.jar were right there on the classpath. Maven had reloaded. Flyway was present. And still: silence.
That's the moment the problem got interesting. The library is on the classpath, the datasource works, and yet the auto-configuration that should pick it up simply isn't firing.
The real cause: Spring Boot 4 modularized auto-configuration
In Spring Boot 3, FlywayAutoConfiguration lived inside the one big spring-boot-autoconfigure jar. If flyway-core was present, the auto-config was present — they were inseparable. I checked the Boot 4.1 jar directly:
# FlywayAutoConfiguration inside spring-boot-autoconfigure-4.1.0?
# NOT present.
It's gone. Spring Boot 4 split auto-configuration out of the monolith into per-technology modules. Flyway's @AutoConfiguration now lives in a dedicated spring-boot-flyway module, registered via that module's own AutoConfiguration.imports. Nothing had pulled it onto my classpath — so flyway-core sat there, fully functional as a library, with no Spring wiring to invoke it. The official source confirms the contract: the spring-boot-starter-flyway starter pulls in this module.
The fix was to stop cherry-picking the raw third-party jar and use the starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
The dependency tree now shows the missing piece — the auto-config module — finally present:
+- org.springframework.boot:spring-boot-starter-flyway:4.1.0
| \- org.springframework.boot:spring-boot-flyway:4.1.0 # the auto-config module
+- org.flywaydb:flyway-database-postgresql:12.4.0
\- org.flywaydb:flyway-core:12.4.0
Restart, and the banner finally showed up, migrations ran, the books printed. The lesson I'm keeping: in Boot 4, a missing auto-config module fails identically to a missing library — total silence. "Library is on the classpath" no longer implies "auto-configured." If a Spring integration does nothing and says nothing, check that its spring-boot-starter-* is present, not just the underlying jar. Starters are the contract now, more than they ever were.
Versioned migrations: schema, data, and entity coupling
With Flyway alive, the versioned migrations are the easy, satisfying part. V1__create_book_table.sql creates the table, V2__seed_books.sql inserts rows, and V3__add_book_genre.sql evolves the schema later:
ALTER TABLE book ADD COLUMN genre VARCHAR(100);
UPDATE book SET genre = 'Software Craftsmanship'
WHERE title IN ('The Pragmatic Programmer', 'Clean Code');
The non-obvious bit is that the migration and the entity have to move together. V3 adds the genre column; the Book record needs a matching genre component, because Spring Data JDBC maps by convention. Add the column without the field and the data is invisible to the app; add the field without the column and you get column "genre" does not exist. Schema and code are two halves of one change. And the backfill belongs in the migration, so every environment converges to the same state.
Repeatable migrations: the R__ trick
The part I actually wanted to understand was repeatable migrations. A versioned migration (V1, V2…) runs once, is immutable, and is checksum-protected — edit an applied one and Flyway refuses to start. A repeatable migration (R__, no version) inverts several of those rules: it runs after all versioned ones, and re-applies whenever its checksum changes — i.e. whenever you edit the file. The mental model that made it click: versioned = a step in history; repeatable = a desired end-state that's cheaper to rewrite than to diff. Views, functions, triggers — declarative objects you'd rather keep as one CREATE OR REPLACE file than as a graveyard of V8__tweak_view, V9__tweak_view_again.
CREATE OR REPLACE VIEW genre_summary AS
SELECT genre,
COUNT(*) AS book_count,
MIN(published) AS earliest_year,
MAX(published) AS latest_year,
ROUND(AVG(published)) AS avg_year
FROM book
GROUP BY genre;
You can see the whole distinction in flyway_schema_history. The versioned rows carry versions 1/2/3; the repeatable row carries a NULL version and is tracked purely by checksum. Edit the view, restart, and that one row mutates in place — new checksum, new installed_rank, no V4. The same one-character edit (V1__ vs R__ in the filename) is forbidden on one and the entire intended workflow on the other. That's the concept, demonstrated rather than described.
What I'm taking away
Two things. First, the Flyway model itself is clean and worth leaning on: versioned migrations for history, repeatable ones for declarative objects, and Spring Boot running them automatically before the datasource is handed to the app. Second, and more broadly: Spring Boot 4's modularization changes a debugging instinct. The old reflex — "the jar's on the classpath, so the feature is on" — is no longer safe. Auto-config now lives in spring-boot-<tech> modules, and the spring-boot-starter-* is what guarantees both the library and its wiring. A silent no-op is the new signature of a missing starter. Cheap lesson, given it only cost me an afternoon and a wall of suspiciously quiet logs.