Five Things Spring Boot 4.1 Doesn't Tell You Until It's Too Late

Update, 2026-10-19: This post was written before Spring Boot 3.5's open-source support ended on June 30, 2026; the text now states that date in the past tense.

Spring Boot 3.5's open-source support ended on June 30, 2026. Boot 4.0 shipped in November on the Spring Framework 7 baseline, 4.1 is current stable. The migration guide exists and is decent. These are the things it doesn't emphasize enough — discovered porting a 28-API reactive WebFlux app to Spring Boot 4.1.0 on Java 25.

Sorted by how much of your afternoon they eat. Worst first.

1. Liquibase Runs. Or Doesn't. Silently.#

Boot 4.0's headline change: modularized autoconfiguration. The monolithic spring-boot-autoconfigure jar split into per-technology modules. Starters pull the right ones. But add dependencies manually and you'll hit this:

<!-- This compiles. This starts. This passes health checks. -->
<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
</dependency>

App boots. /health returns UP. Mocked service-layer tests pass. Everything looks fine until the first real query hits Postgres:

relation "survey_result" does not exist

The Liquibase autoconfiguration now lives in a separate module. Without it, Spring sees liquibase-core on the classpath and does nothing with it. No warning. No "you have Liquibase but no autoconfig." Just a schema that never gets created.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-liquibase</artifactId>
</dependency>

Every feedback loop that should catch this — compilation, startup, health check, unit tests — says everything is fine. Only an integration test that actually touches the database surfaces it. The docker smoke test caught mine. CI suites that mock the data layer won't.

2. @WebFluxTest Moved House#

Same modularization, louder failure. @WebFluxTest migrated to a new module and a new package:

// Boot 3.x
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;

// Boot 4.x
import org.springframework.boot.webflux.test.autoconfigure.WebFluxTest;

Without the module, the import doesn't resolve. At least this one yells at compile time:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-webflux-test</artifactId>
    <scope>test</scope>
</dependency>

Also available as spring-boot-starter-webflux-test, which transitively pulls spring-boot-starter-test — so you can drop the separate spring-boot-starter-test dependency if you switch.

3. Testcontainers Is 2.x Now#

Boot 4.1's BOM pins Testcontainers 2.0.5. Modules got renamed with a testcontainers- prefix:

<!-- Boot 3.x / Testcontainers 1.x -->
<artifactId>postgresql</artifactId>

<!-- Boot 4.x / Testcontainers 2.x -->
<artifactId>testcontainers-postgresql</artifactId>

Old test code still compiles: testcontainers-postgresql 2.0.5 keeps org.testcontainers.containers.PostgreSQLContainer, next to the new home org.testcontainers.postgresql.PostgreSQLContainer. The old artifact ID fails loudly, which is the good kind of failure: the Testcontainers 2.0.5 BOM that Boot 4.1 imports no longer manages org.testcontainers:postgresql, so a version-less dependency stops the build with a missing-version error before anything compiles.

Second trap: R2DBC @ServiceConnection needs testcontainers-r2dbc as a separate dependency:

NoClassDefFoundError: org/testcontainers/r2dbc/R2DBCDatabaseContainer

Full working set for reactive Postgres tests:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-postgresql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-r2dbc</artifactId>
    <scope>test</scope>
</dependency>

4. The Native Build That Builds Nothing#

./mvnw -Pnative package runs Spring's process-aot phase but does not invoke native:compile. No binary is produced. No error. Just a target/ directory full of AOT-generated sources and no executable. The Dockerfile's COPY of the binary is the first thing to notice.

Call the goal yourself:

./mvnw -Pnative -DskipTests native:compile

In the same commit I pinned the binary name, so the Dockerfile's COPY target/spring-native-as-hell matches something written down in the POM:

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
    <configuration>
        <imageName>spring-native-as-hell</imageName>
    </configuration>
</plugin>

That pin is documentation, not a fix. native-maven-plugin 1.1.1, the version Boot 4.1.0 manages, already defaults imageName to ${project.artifactId}, which here is spring-native-as-hell. The missing binary was the command's fault alone.

5. Micrometer Metric Names Changed#

Won't break your app. Will break your dashboards. This one is specific to cross-framework ports — if you're migrating from Boot 3.x, these have always been the metric names.

Spring Boot exposes R2DBC pool metrics with a _connections suffix that Micronaut doesn't use:

MetricSpring Boot 4.xMicronaut
Acquiredr2dbc_pool_acquired_connectionsr2dbc_pool_acquired
Idler2dbc_pool_idle_connectionsr2dbc_pool_idle
Pendingr2dbc_pool_pending_connectionsr2dbc_pool_pending

Prometheus scraper scripts, Grafana dashboards, k6 threshold checks — anything that parses these names silently reads zero. The scraper doesn't error. The metric just doesn't match. The column is empty.

The Good News#

The native build itself was clean. Spring AOT plus the GraalVM reachability-metadata repository handled Liquibase and Spring internals automatically. The only manual annotation: @RegisterReflectionForBinding on the config class listing the 28 DTOs deserialized by WebClient:

@Configuration(proxyBeanMethods = false)
@RegisterReflectionForBinding({
    ApodResponse.class, AqiResponse.class, AtmosphericCo2Response.class,
    CmeResponse.class, DebrisAlertResponse.class, EarthquakeResponse.class,
    // ... 22 more DTOs
})
public class HttpClientsConfig { ... }

Controller return types are inferred by Spring AOT — no annotation needed. 151 MB binary, 1m17s build on GraalVM CE 25.0.2, boots in 0.58 seconds on debian:bookworm-slim.

Most gotchas on this list trace back to one thing: a starter used to pull this in for you, and now it doesn't. The split makes sense — smaller jars, faster startup, less classpath noise. But the failure modes range from "compiler yells" (good) to "app starts, tests pass, production breaks" (less good).

Check your Liquibase first.

Filed under: java, spring-boot, graalvm, migration

Sources#