Spring Boot 3.x Migration: From Java 11 to Java 21, Namespace Changes, and Native Image
Millions of production applications still run on Spring Boot 2.x — many on Java 8 or Java 11 — and the migration to Spring Boot 3.x is the single most impactful upgrade available to Java teams right now. Spring Boot 3.x delivers Spring Framework 6, Jakarta EE 10, Java 17 as the minimum baseline, virtual thread support on Java 21, GraalVM native image, and a completely modernised observability stack. This guide gives you every breaking change, migration step, code fix, and automation command needed to get your application running on Spring Boot 3.x — correctly and safely.
Part of the Spring Boot Engineering Series.
Table of Contents
- Why Migrate? The Case for Spring Boot 3.x
- Pre-Migration Checklist
- Step 1: Java Version Upgrade (11 → 17 → 21)
- Step 2: The javax → jakarta Namespace Change
- Step 3: Spring Framework 6 Breaking Changes
- Step 4: Spring Security 6 Migration
- Step 5: Hibernate 6 & Spring Data 3.x
- Step 6: Build & Dependency Updates
- Step 7: Testing Layer Changes
- Step 8: Observability & Actuator
- Native Image: What You Unlock After Migration
- Automation Tools: OpenRewrite & Spring Boot Migrator
- Common Migration Errors & Fixes
- Spring 6 HTTP Interfaces: Replace Feign & RestTemplate
- Configuration Properties Changes in Boot 3.x
- Multi-Module Migration Strategy
- Performance Gains: Boot 2.x vs 3.x
- Migration Effort Estimation by Project Size
- Conclusion
Why Migrate? The Case for Spring Boot 3.x
Spring Boot 2.x reached end-of-life in November 2023. No further security patches, dependency updates, or bug fixes are being released for any 2.x version. Running a production application on an EOL framework is a serious security and operational risk. But beyond security, Spring Boot 3.x is a generational upgrade that delivers:
- Spring Framework 6 — rewired for Jakarta EE 10, HTTP interfaces, problem-detail RFC 7807, improved AOP performance
- Java 17 baseline — full access to records, sealed classes, pattern matching, text blocks, and 6 years of JVM performance improvements
- Java 21 support — virtual threads (Project Loom) for high-throughput I/O with
spring.threads.virtual.enabled=true, structured concurrency, sequenced collections - GraalVM native image — compile your Spring Boot application to a standalone binary with 80ms startup and 60 MB memory footprint
- Built-in OpenTelemetry — Micrometer Tracing replaces Spring Cloud Sleuth; OTEL auto-instrumentation works out of the box
- Hibernate 6.x + JPA 3.1 — improved query performance, better UUID support, simplified HQL, Jakarta namespace
- Spring Security 6 — simpler, Lambda-DSL based configuration replacing the deprecated adapter pattern
For teams on Java 11 with Spring Boot 2.7.x, the migration delivers a measurable reduction in response latency (20–40% from JVM improvements alone), lower GC pause times from G1GC/ZGC improvements in Java 17–21, and eliminates a large class of null-pointer vulnerabilities through better language features. The investment is real — this is a breaking-change upgrade — but so is the reward.
Pre-Migration Checklist
Before writing a single line of migration code, complete this checklist. Skipping steps here is the primary cause of failed migrations.
- Audit your direct and transitive dependencies — run
mvn dependency:treeorgradle dependencies. Flag every library that usesjavax.*imports. Libraries not updated for Jakarta EE 10 will cause runtime failures that are hard to diagnose. - Check the Spring Boot 3.x migration guide at spring.io and the official 3.0 migration wiki.
- Ensure 100% test coverage on critical paths before starting. The migration will uncover bugs you did not know existed. You need passing tests as your baseline.
- Check your application server / deployment target. If you deploy to Tomcat, ensure Tomcat 10.x (not 9.x) — Tomcat 10 supports Jakarta EE 9+. Wildfly 27+, Payara 6+, and GlassFish 7 support Jakarta EE 10.
- Identify Spring Cloud dependencies. Spring Cloud 2022.x aligns with Spring Boot 3.0. Spring Cloud 2023.x aligns with Spring Boot 3.2. Ensure your Spring Cloud version is compatible.
- Document your Spring Security configuration. The security configuration is the most labour-intensive part of the migration — document the existing filter chain, role model, and OAuth2 flows before starting.
- Migrate one module at a time in multi-module projects. Attempting a big-bang migration across 30 modules simultaneously is the leading cause of stalled Spring Boot migrations.
Before jumping directly to Spring Boot 3.x, migrate to Spring Boot 2.7.x first. The 2.7.x release was explicitly designed as a stepping stone — it deprecates every API that is removed in 3.x, enables the spring-boot-properties-migrator, and prints deprecation warnings at startup for removed properties. Fix all deprecation warnings on 2.7.x, then upgrade to 3.x. Teams that use this approach spend significantly less time debugging Boot 3.x startup failures.
Step 1: Java Version Upgrade (11 → 17 → 21)
Spring Boot 3.x requires Java 17 as the minimum. Java 21 (LTS) is recommended for production deployments because of virtual thread support. The recommended upgrade path for teams on Java 11 is Java 11 → Java 17 → Java 21, validating at each step.
What breaks between Java 11 and Java 17
- Strong encapsulation of JDK internals: Any code that accesses internal JDK APIs via reflection (e.g.,
sun.misc.Unsafe, internalcom.sun.*classes) will throwInaccessibleObjectExceptionat runtime. Run your application with--add-opensflags temporarily to identify violations, then fix them at source. - Removed APIs:
RMISecurityManager,SecurityManager(deprecated in 17, removed in 21),Applet API, Nashorn JavaScript engine. Replace Nashorn usages with GraalVM's Polyglot API or a dedicated scripting library. - Preview features: If you used any preview features (with
--enable-preview) from older Java versions, verify they are now stable APIs or still require the flag.
What you gain in Java 17
// Records — replace boilerplate DTOs
public record OrderRequest(String productId, int quantity, BigDecimal price) {}
// Sealed classes — exhaustive type hierarchies
public sealed interface PaymentResult permits Success, Failure, Pending {}
public record Success(String transactionId) implements PaymentResult {}
public record Failure(String reason, int errorCode) implements PaymentResult {}
// Pattern matching instanceof — eliminate cast boilerplate
if (result instanceof Success s) {
log.info("Transaction {}", s.transactionId());
}
// Text blocks — clean SQL and JSON in tests
String sql = """
SELECT o.id, o.status, c.email
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > :since
""";
// Switch expressions — exhaustive, value-returning
String label = switch (result) {
case Success s -> "OK: " + s.transactionId();
case Failure f -> "ERR: " + f.reason();
case Pending p -> "PENDING";
};
Enabling virtual threads on Java 21
After migrating to Spring Boot 3.2+ on Java 21, enable virtual threads with a single property. This replaces the platform thread pool in Tomcat with virtual threads, allowing millions of concurrent I/O-blocking operations without thread pool saturation:
# application.properties
spring.threads.virtual.enabled=true
This single line replaces the platform thread executor in Tomcat (and other embedded servers) with a virtual thread executor. No thread pool sizing, no maxThreads tuning — virtual threads scale automatically. Synchronous blocking code (JDBC, file I/O, HTTP client calls) now runs without holding an OS thread during the block.
Virtual threads are pinned (blocked on their carrier OS thread) when executing inside a synchronized block or calling a native method. For I/O-heavy code, replace synchronized blocks with ReentrantLock to avoid carrier thread starvation. JDBC drivers (HikariCP 5.1+, PostgreSQL 42.6+) have been patched for virtual thread compatibility.
Java & Spring Boot version compatibility matrix
| Spring Boot | Min Java | Recommended Java | Spring Framework | EOL |
|---|---|---|---|---|
| 2.7.x | Java 8 | Java 11 | 5.3.x | Nov 2023 — EOL |
| 3.0.x | Java 17 | Java 17 | 6.0.x | Nov 2023 — EOL |
| 3.1.x | Java 17 | Java 17/21 | 6.0.x | Aug 2024 — EOL |
| 3.2.x | Java 17 | Java 21 | 6.1.x | Nov 2024 — EOL |
| 3.3.x | Java 17 | Java 21 | 6.1.x | Active (until Aug 2025) |
| 3.4.x | Java 17 | Java 21/23 | 6.2.x | Active (LTS candidate) |
For new migrations, target Spring Boot 3.3.x or 3.4.x with Java 21. Java 21 is the current LTS — it gives you virtual threads, sequenced collections, record patterns, and the best JVM performance available without chasing preview features.
Step 2: The javax → jakarta Namespace Change
This is the most pervasive mechanical change in the entire migration. When the Eclipse Foundation took stewardship of Java EE and renamed it Jakarta EE, Oracle retained the rights to the javax.* package namespace. Jakarta EE 9 moved all APIs from javax.* to jakarta.*. Spring Boot 3.x requires Jakarta EE 9+ and uses Jakarta EE 10 throughout. Every import in every Java file that references a Jakarta EE API must be updated.
What changes — and what does not
The rule is simple: the javax.* packages that are part of Jakarta EE move to jakarta.*. The core Java standard library packages — java.util.*, java.lang.*, java.io.*, java.net.* — are completely unaffected. The packages that change include:
| Old (Spring Boot 2.x) | New (Spring Boot 3.x) |
|---|---|
javax.servlet.* | jakarta.servlet.* |
javax.persistence.* | jakarta.persistence.* |
javax.validation.* | jakarta.validation.* |
javax.transaction.* | jakarta.transaction.* |
javax.inject.* | jakarta.inject.* |
javax.annotation.* | jakarta.annotation.* |
javax.mail.* | jakarta.mail.* |
javax.xml.ws.* | jakarta.xml.ws.* |
Automating the namespace change with OpenRewrite
# Run the full Spring Boot 3.3 upgrade recipe (includes namespace migration)
./mvnw -U rewrite:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:LATEST \
-Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
# Or apply the namespace migration recipe in isolation
./mvnw -U rewrite:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:LATEST \
-Drewrite.activeRecipes=org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta
OpenRewrite performs a syntax-tree-level transformation of your source code. It is not a plain text find-and-replace — it understands Java imports, fully qualified class names in annotations, and string literals used in reflection, and updates them correctly. After running OpenRewrite, review the diff carefully: it handles 95% of the namespace changes automatically. The remaining 5% are typically in configuration files, SQL scripts, or third-party library usage that requires manual intervention.
Manual validation after namespace migration
After running the automated recipe, grep your codebase for residual javax.* references that are Jakarta EE APIs (not standard Java): grep -rn "import javax\." --include="*.java" src/. Investigate every match. If you find javax.swing.* or javax.sound.* those are standard Java and do not change. If you find javax.servlet.* or javax.persistence.*, the automation missed them — fix manually.
Step 3: Spring Framework 6 Breaking Changes
Spring Framework 6 removes APIs that were deprecated in Spring Framework 5.x. The most common removals that affect production codebases:
Removed: HttpMethod enum → value object
HttpMethod is no longer a Java enum in Spring 6. It is now a class (value object). Code that uses switch on HttpMethod values, passes them to methods expecting enum types, or uses HttpMethod.values() will fail to compile.
// Spring Boot 2.x — HttpMethod was an enum
switch (request.getMethod()) {
case "GET": handleGet(); break;
case "POST": handlePost(); break;
}
// Spring Boot 3.x — HttpMethod is a value object; use if-else or equals()
HttpMethod method = HttpMethod.valueOf(request.getMethod());
if (HttpMethod.GET.equals(method)) {
handleGet();
} else if (HttpMethod.POST.equals(method)) {
handlePost();
}
Removed: RestTemplate-based WebTestClient
The legacy RestTemplate-based test utilities that wrapped MockMvc have been removed. Use MockMvcWebTestClient or WebTestClient with a MockMvcHttpConnector instead:
// Spring Boot 3.x integration test with WebTestClient
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderControllerIT {
@Autowired
private WebTestClient webTestClient;
@Test
void createOrder_returnsCreated() {
webTestClient.post()
.uri("/api/orders")
.bodyValue(new OrderRequest("SKU-001", 2, new BigDecimal("29.99")))
.exchange()
.expectStatus().isCreated()
.expectBody(OrderResponse.class)
.value(r -> assertThat(r.orderId()).isNotNull());
}
}
Removed: spring-orm legacy JPA support
JpaTemplate and JpaDaoSupport — the JPA equivalents of HibernateTemplate — were removed. These were already unnecessary since Spring 3.0 recommended direct EntityManager injection. Replace them with direct @PersistenceContext EntityManager em injection.
Changed: PathMatchingResourcePatternResolver
Spring Framework 6.0.10+ changed how classpath scanning handles jar file patterns. Applications that relied on classpath*: patterns scanning inside nested JARs may need to use explicit resource patterns instead of wildcards.
Changed: @RequestMapping produces/consumes defaults
In Spring 6, handler methods no longer inherit produces and consumes from the class-level @RequestMapping annotation in all scenarios. Declare produces and consumes explicitly on each method-level mapping if you need consistent content negotiation.
Step 4: Spring Security 6 Migration
Spring Security 6 is the part of the migration that most teams underestimate. The WebSecurityConfigurerAdapter class, which was the standard way to configure Spring Security for over a decade, was deprecated in Spring Security 5.7 and removed in Spring Security 6. All security configuration must now use component-based configuration with a SecurityFilterChain bean.
Migrating WebSecurityConfigurerAdapter
// Spring Boot 2.x — extending WebSecurityConfigurerAdapter (removed in 3.x)
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt();
}
}
// Spring Boot 3.x — SecurityFilterChain bean (Lambda DSL)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
}
Key Spring Security 6 changes
antMatchers()removed — replaced byrequestMatchers(). The Ant pattern semantics are the same, but the method name changed.authorizeRequests()removed — replaced byauthorizeHttpRequests(). The new method usesAuthorizationManagerinternally, providing better performance and extensibility.- Method security —
@EnableGlobalMethodSecurity(prePostEnabled = true)is replaced by@EnableMethodSecurity. The new annotation enables@PreAuthorize,@PostAuthorize,@PreFilter, and@PostFilterby default. - CSRF defaults changed — CSRF protection is now enabled by default with
CookieCsrfTokenRepository.withHttpOnlyFalse()for SPA applications. Stateless REST APIs should explicitly disable CSRF:.csrf(AbstractHttpConfigurer::disable). - OAuth2 resource server — the JWT decoder configuration is now more explicit. Use
JwtDecoders.fromIssuerLocation(issuerUri)or configurespring.security.oauth2.resourceserver.jwt.issuer-uriin properties.
Migrating UserDetailsService
// Spring Boot 3.x — UserDetailsService as a bean (unchanged API, new wiring)
@Bean
public UserDetailsService userDetailsService(UserRepository repo) {
return username -> repo.findByEmail(username)
.map(user -> User.withUsername(user.email())
.password(user.passwordHash())
.roles(user.roles().toArray(String[]::new))
.build())
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
Step 5: Hibernate 6 & Spring Data 3.x
Spring Boot 3.x ships with Hibernate 6.x (previously 5.x in Boot 2.x) and Spring Data 3.x. Hibernate 6 is a significant release with changed APIs, improved performance, and full Jakarta EE 10 compliance.
Hibernate 6 breaking changes
@Typeannotation changed: Thetypeattribute now takes a class reference instead of a string type name. The old string-based approach (@Type(type = "json")) is removed. Use@Type(JsonType.class)with the Hypersistence Utils library, or use built-in Hibernate JSON support.- UUID mapping changed: Hibernate 6 maps
UUIDtypes differently across databases. PostgreSQL now uses the native UUID column type automatically. If you were using a varchar-based UUID storage strategy, test carefully — the column DDL may change. - Criteria API changes: Some Criteria API internal methods changed. If you use the JPA Criteria API directly (not through Spring Data Specifications), review your query construction code.
ImplicitNamingStrategydefaults: Hibernate 6 changed some default naming conventions. Review your schema to ensure generated table and column names match your existing database schema. Setspring.jpa.hibernate.naming.physical-strategyexplicitly to avoid surprises.
// Spring Boot 2.x — Hibernate 5 @Type with string type name
@Column(columnDefinition = "jsonb")
@Type(type = "jsonb")
private Map<String, Object> metadata;
// Spring Boot 3.x — Hibernate 6 with @JdbcTypeCode for JSON
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Column(columnDefinition = "jsonb")
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> metadata;
Spring Data 3.x changes
- Repository return types:
Page,Slice, andListreturn types work as before. However,Optionalwrapping behaviour for derived queries that return multiple results changed — queries that could return multiple rows now correctly throwIncorrectResultSizeDataAccessExceptionrather than silently returning the first result. - Auditing:
@EnableJpaAuditingand@CreatedDate/@LastModifiedDatenow work seamlessly with Java records — you can use records for auditable entity projections. - Scroll API: Spring Data 3.1 introduced the new
ScrollPosition-based cursor pagination API. This replaces offset-based pagination for large datasets and is significantly more efficient.
Step 6: Build & Dependency Updates
Maven pom.xml updates
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version> <!-- Use latest 3.x patch -->
</parent>
<properties>
<java.version>21</java.version>
</properties>
<!-- Spring Cloud must align with Boot 3.x -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2023.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Gradle build.gradle updates
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.5'
id 'io.spring.dependency-management' version '1.1.6'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Flyway 9+ for Jakarta EE 10 compatibility
implementation 'org.flywaydb:flyway-core'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
Key dependency version changes
| Library | Spring Boot 2.x version | Spring Boot 3.x version |
|---|---|---|
| Spring Framework | 5.3.x | 6.1.x |
| Hibernate | 5.6.x | 6.4.x |
| Spring Security | 5.7.x | 6.2.x |
| Spring Data | 2.7.x | 3.2.x |
| Tomcat | 9.x | 10.x |
| Flyway | 8.x | 9.x / 10.x |
| Liquibase | 4.x | 4.24+ |
| Micrometer | 1.9.x | 1.12.x (OTEL bridge built-in) |
Spring Cloud compatibility matrix
Spring Cloud versioning is the most common source of dependency resolution errors during Boot 3.x migrations. The Spring Cloud release train name changed from calver (2021, 2022) to sequenced calver aligned with Spring Boot:
| Spring Cloud Release | Spring Boot Compatibility | Key Components |
|---|---|---|
| 2021.0.x (Jubilee) | Boot 2.6.x | Sleuth (tracing), Gateway, Eureka |
| 2022.0.x (Kilburn) | Boot 3.0.x / 3.1.x | Sleuth removed; Micrometer Tracing; Gateway 4.x |
| 2023.0.x (Leyton) | Boot 3.2.x / 3.3.x | OpenFeign 4.x; Gateway 4.1.x; Kubernetes Service Discovery |
| 2024.0.x (Moorgate) | Boot 3.4.x | Latest; fully removes javax.* dependencies |
Spring Cloud OpenFeign 4.x (part of the 2022.x / 2023.x releases) is fully compatible with Spring Boot 3.x and uses jakarta.* imports. However, Feign clients that use the old feign.codec.Encoder / feign.codec.Decoder directly may need to be updated. Consider migrating to Spring 6 HTTP Interfaces for new HTTP client code — it removes the Spring Cloud OpenFeign dependency entirely for simple REST clients.
Step 7: Testing Layer Changes
The testing layer in Spring Boot 3.x is largely backward-compatible, but several common testing patterns require updates.
MockMvc with Spring Security 6
// Spring Boot 3.x @WebMvcTest with Spring Security 6
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService orderService;
@Test
@WithMockUser(roles = "USER")
void getOrder_authenticated_returnsOk() throws Exception {
given(orderService.findById(1L)).willReturn(Optional.of(testOrder()));
mvc.perform(get("/api/orders/1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1));
}
@Test
void getOrder_unauthenticated_returns401() throws Exception {
mvc.perform(get("/api/orders/1"))
.andExpect(status().isUnauthorized());
}
}
Testcontainers with Spring Boot 3.1+ service connections
Spring Boot 3.1 introduced first-class Testcontainers support with @ServiceConnection. This eliminates the boilerplate of manually setting spring.datasource.url from the container's JDBC URL:
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
@Container
@ServiceConnection // auto-configures spring.datasource.* from the container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection // auto-configures spring.data.redis.*
static RedisContainer redis = new RedisContainer("redis:7-alpine");
@Autowired OrderRepository orderRepo;
@Test
void saveAndRetrieveOrder() {
Order saved = orderRepo.save(new Order("product-1", 2));
assertThat(orderRepo.findById(saved.getId())).isPresent();
}
}
Step 8: Observability & Actuator
Spring Boot 3.x ships Micrometer 1.10+ with the Micrometer Tracing bridge built in. Spring Cloud Sleuth — the previous distributed tracing solution — is discontinued and replaced by Micrometer Tracing. Migrating from Sleuth to Micrometer Tracing requires:
- Remove
spring-cloud-starter-sleuthfrom your dependencies. - Add
io.micrometer:micrometer-tracing-bridge-brave(for Zipkin/Brave backend) orio.micrometer:micrometer-tracing-bridge-otel(for OpenTelemetry backend). - Add the exporter:
io.zipkin.reporter2:zipkin-reporter-bravefor Zipkin, orio.opentelemetry:opentelemetry-exporter-otlpfor OTEL collector.
# application.properties — OpenTelemetry tracing in Spring Boot 3.x
management.tracing.sampling.probability=1.0
management.otlp.tracing.endpoint=http://otel-collector:4318/v1/traces
# Actuator — expose all endpoints (secure appropriately)
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=always
management.endpoint.health.probes.enabled=true
# Problem Details (RFC 7807) — new in Spring Boot 3.x
spring.mvc.problemdetails.enabled=true
The new spring.mvc.problemdetails.enabled=true property activates Spring's built-in RFC 7807 Problem Details support. @ExceptionHandler methods that return ProblemDetail now automatically produce the standard application/problem+json content type, giving API consumers consistent error payloads without custom exception mappers.
Problem Details RFC 7807 — complete example
// Spring Boot 3.x — ProblemDetail (@ExceptionHandler)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleOrderNotFound(OrderNotFoundException ex, HttpServletRequest req) {
ProblemDetail problem = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setType(URI.create("https://mdsanwarhossain.me/errors/order-not-found"));
problem.setTitle("Order Not Found");
problem.setProperty("orderId", ex.getOrderId());
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail
.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, "Validation failed");
problem.setTitle("Invalid Request");
problem.setProperty("violations", ex.getBindingResult().getFieldErrors()
.stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
.toList());
return problem;
}
}
// Response body (application/problem+json):
// {
// "type": "https://mdsanwarhossain.me/errors/order-not-found",
// "title": "Order Not Found",
// "status": 404,
// "detail": "Order 12345 does not exist",
// "orderId": 12345,
// "timestamp": "2026-04-05T16:00:00Z"
// }
If you use spring-cloud-starter-sleuth, remove it entirely before upgrading to Spring Boot 3.x. It is incompatible with Boot 3.x. Replace it with io.micrometer:micrometer-tracing-bridge-otel + io.opentelemetry:opentelemetry-exporter-otlp. Your log MDC fields (traceId, spanId) are automatically populated by Micrometer Tracing — no extra configuration needed.
Native Image: What You Unlock After Migration
Completing the migration to Spring Boot 3.x opens the door to GraalVM native image compilation — something impossible on Spring Boot 2.x. Once your application builds and runs correctly on Spring Boot 3.x with Java 17+, enabling native image is as simple as adding a Maven profile and running one command:
<!-- pom.xml — native image profile -->
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<!-- Build the native binary -->
<!-- ./mvnw -Pnative native:compile -->
The native image results speak for themselves: a Spring Boot 3.x microservice that starts in 4 seconds on the JVM starts in under 100 milliseconds as a native binary. Memory footprint drops from ~256 MB to ~60 MB. Container images shrink from ~250 MB to ~35 MB. For serverless functions, scale-to-zero architectures, and cost-sensitive Kubernetes deployments, this is transformative.
Automation Tools: OpenRewrite & Spring Boot Migrator
Two tools significantly reduce the manual effort in a Spring Boot 3.x migration:
OpenRewrite
OpenRewrite is an automated code refactoring tool. The rewrite-spring recipe library contains recipes for the complete Spring Boot 3.x migration. Run the full upgrade recipe, which chains together all individual recipes for namespace changes, API changes, security config updates, and dependency version bumps:
# Dry-run first — see what OpenRewrite will change without applying
./mvnw -U rewrite:dryRun \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:LATEST \
-Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
# Apply the changes
./mvnw -U rewrite:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:LATEST \
-Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
Spring Boot Migrator (SBM)
The Spring Boot Migrator is an experimental CLI tool from the Spring team that scans your project and produces a detailed migration report. It identifies specific migration tasks (namespace changes, removed APIs, configuration file format changes) and can apply some of them automatically. Use it as a complementary audit tool alongside OpenRewrite.
Common Migration Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
ClassNotFoundException: javax.servlet.Filter |
Third-party dependency still uses javax.servlet |
Upgrade the dependency to its Jakarta EE 10-compatible version |
NoSuchMethodError: WebSecurityConfigurerAdapter |
Security config extends removed adapter class | Convert to SecurityFilterChain bean (see Step 4 above) |
ConverterNotFoundException for UUID |
Hibernate 6 changed UUID type mapping defaults | Add @Column(columnDefinition = "uuid") on UUID fields for PostgreSQL |
BeanCreationException: method security |
@EnableGlobalMethodSecurity removed |
Replace with @EnableMethodSecurity |
Flyway fails: DB not clean |
Flyway 9 changed default schema validation | Set spring.flyway.validate-on-migrate=false then re-enable after validation |
IllegalArgumentException: antMatchers |
antMatchers() removed from Spring Security 6 |
Replace antMatchers() with requestMatchers() |
HttpMessageNotWritableException for problem details |
New default error response format in Spring Boot 3.x | Update API client response parsing; use ProblemDetail standard |
| Spring Cloud config not connecting | Spring Cloud version mismatch with Boot 3.x | Use Spring Cloud 2022.x (Boot 3.0/3.1) or 2023.x (Boot 3.2+) |
Performance Gains: Boot 2.x vs 3.x
Beyond correctness, the migration delivers measurable performance improvements on the same hardware. The gains come from three sources: JVM improvements from Java 17–21, Spring Framework 6 optimisations (particularly in AOP and HTTP routing), and Hibernate 6's improved query generation.
| Metric | Spring Boot 2.7 / Java 11 | Spring Boot 3.3 / Java 21 | Spring Boot 3.3 / Native |
|---|---|---|---|
| Cold startup | ~8s | ~4s | ~80ms |
| Heap at idle | ~512 MB | ~256 MB | ~60 MB |
| Throughput (req/s) | Baseline | +25–40% (ZGC + VT) | +10–15% vs JVM after warmup |
| P99 latency (1000 RPS) | ~120ms | ~65ms | ~55ms |
| GC pause (G1) | ~80ms avg | <10ms (ZGC) | N/A (serial GC) |
The throughput gain on Java 21 with virtual threads is most pronounced for I/O-heavy workloads — services that make downstream HTTP calls, execute JDBC queries, or interact with message brokers. For CPU-bound workloads (compute-intensive transformations), the gain is more modest (5–15%). The native image numbers show a different trade-off: peak throughput is slightly lower than JIT-optimised JVM (no JIT specialisation), but startup and memory are dramatically better.
Spring 6 HTTP Interfaces: Replace Feign & RestTemplate
One of the most exciting additions in Spring Framework 6 (available since Spring Boot 3.0) is HTTP Interfaces — a declarative, annotation-driven HTTP client powered by @HttpExchange. This is Spring's answer to OpenFeign, built natively into the framework without the need for Spring Cloud dependencies. For teams migrating from Boot 2.x, this is a compelling reason to remove the Feign dependency entirely.
Before: Feign client (Spring Boot 2.x)
// Spring Boot 2.x — Feign client (requires spring-cloud-starter-openfeign)
@FeignClient(name = "payment-service", url = "${payment.service.url}")
public interface PaymentClient {
@PostMapping("/payments")
PaymentResponse charge(@RequestBody PaymentRequest request);
@GetMapping("/payments/{id}")
PaymentResponse getPayment(@PathVariable String id);
@DeleteMapping("/payments/{id}")
void cancelPayment(@PathVariable String id);
}
After: Spring 6 HTTP Interfaces (Spring Boot 3.x)
// Spring Boot 3.x — HTTP Interfaces with @HttpExchange (no Feign needed)
public interface PaymentClient {
@PostExchange("/payments")
PaymentResponse charge(@RequestBody PaymentRequest request);
@GetExchange("/payments/{id}")
PaymentResponse getPayment(@PathVariable String id);
@DeleteExchange("/payments/{id}")
void cancelPayment(@PathVariable String id);
}
// Configuration: register the HTTP Interface as a bean
@Configuration
public class HttpClientConfig {
@Bean
public PaymentClient paymentClient(
@Value("${payment.service.url}") String baseUrl) {
// Use RestClient (new in Boot 3.2) as the underlying HTTP client
RestClient restClient = RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
RestClientAdapter adapter = RestClientAdapter.create(restClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(adapter)
.build();
return factory.createClient(PaymentClient.class);
}
}
// Usage: inject and call exactly like Feign
@Service
public class CheckoutService {
private final PaymentClient paymentClient;
public CheckoutService(PaymentClient paymentClient) {
this.paymentClient = paymentClient;
}
public Receipt processPayment(OrderDetails order) {
PaymentResponse response = paymentClient.charge(
new PaymentRequest(order.total(), order.currency())
);
return new Receipt(response.transactionId(), response.timestamp());
}
}
- Use HTTP Interfaces for new projects on Spring Boot 3.x — no Spring Cloud dependency, supports both reactive (WebClient) and synchronous (RestClient) transports
- Keep Feign if your team is already deeply invested in Spring Cloud OpenFeign and relies on Feign-specific interceptors or contract testing with Pact
- HTTP Interfaces support
@ExchangeAttributefor passing runtime values (e.g., bearer tokens) without custom interceptors RestClient(introduced in Spring Boot 3.2) is the synchronous replacement forRestTemplate— fluent API, modern design, full HTTP Interfaces support
RestClient: the modern RestTemplate replacement
// Spring Boot 3.2+ — RestClient (replaces RestTemplate)
RestClient restClient = RestClient.create("https://api.example.com");
// GET with response body mapping
UserProfile profile = restClient.get()
.uri("/users/{id}", userId)
.retrieve()
.body(UserProfile.class);
// POST with error handling
restClient.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(orderRequest)
.retrieve()
.onStatus(status -> status.is4xxClientError(), (req, res) -> {
throw new OrderException("Order rejected: " + res.getStatusCode());
})
.toBodilessEntity();
Configuration Properties Changes in Boot 3.x
Spring Boot 3.x renamed, restructured, or removed a significant number of configuration properties. The most impactful changes — the ones that cause silent misconfiguration (not startup failures) — are listed below.
| Boot 2.x Property | Boot 3.x Replacement | Notes |
|---|---|---|
spring.redis.* |
spring.data.redis.* |
Old prefix still works in 3.0/3.1 but removed in 3.2 |
spring.mongodb.* |
spring.data.mongodb.* |
Same pattern as Redis |
spring.datasource.initialization-mode |
spring.sql.init.mode |
Values: always / embedded / never |
management.metrics.export.prometheus.* |
management.prometheus.metrics.export.* |
Restructured under management.{registry}.* |
spring.security.oauth2.client.registration.* |
Unchanged | But resource-server.jwt.jwk-set-uri requires explicit config |
server.max-http-header-size |
server.max-http-request-header-size |
Renamed for clarity in Boot 3.x |
spring.jpa.open-in-view |
Unchanged (default: true) | Explicitly set to false for REST APIs — avoids session leaks |
spring.mvc.pathmatch.use-suffix-pattern |
Removed | Suffix pattern matching removed entirely in Boot 3.x |
Running the properties migration check
# Add the Spring Boot Properties Migrator (temporary dependency)
# It prints warnings for deprecated / renamed properties at startup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>
# After verifying all properties, REMOVE this dependency before deploying to production
The spring-boot-properties-migrator dependency scans your application.properties / application.yml files at startup and logs warnings for every deprecated, renamed, or removed property, with the replacement property name. It is a read-only diagnostic tool — it does not modify your configuration files. Add it for one startup, fix all flagged properties, then remove it.
Multi-Module Migration Strategy
Large enterprise Spring Boot applications are commonly structured as multi-module Maven or Gradle projects — dozens of modules sharing common dependencies through a parent BOM. Attempting a big-bang migration across all modules simultaneously is the leading cause of failed Spring Boot migrations. A module-by-module strategy, with explicit dependency isolation at each step, dramatically reduces risk.
In a multi-module project, if one module has migrated to jakarta.* but another still uses javax.*, and they share a classpath (e.g., through a common domain module), you will get ClassCastException or ClassNotFoundException at runtime — not compile time. The JVM loads both javax.persistence.Entity and jakarta.persistence.Entity as distinct classes. Entities annotated with one will not be recognised by a JPA provider expecting the other. All modules on the shared classpath must complete the namespace migration together.
Recommended multi-module migration order
- Domain / core module — contains entities, value objects, domain events. Migrate first. This is the highest-impact module since every other module depends on it. After migrating domain, all consumers inherit the Jakarta namespace. Validate thoroughly with Hibernate's schema validation (
spring.jpa.hibernate.ddl-auto=validate) against your existing database. - Persistence / repository module — Spring Data repositories, custom query methods, Flyway/Liquibase migrations. Migrate second. Ensure Flyway is upgraded to 9.x+ with Jakarta EE support.
- Service / application module — business logic, transaction boundaries, Spring Security rules. Migrate third. This is where Spring Security 6 changes land.
- API / web module — REST controllers, request/response DTOs, validation, OpenAPI spec. Migrate fourth. This is where
@RequestMappingchanges and Problem Details integration apply. - Integration / messaging modules — Kafka consumers, RabbitMQ listeners, async event handlers. Migrate last, as they often have the most third-party dependencies that need Jakarta-compatible versions.
Keeping modules on Boot 2.x and 3.x simultaneously (bridge strategy)
For very large codebases, a complete simultaneous migration is impractical. The bridge strategy uses an anti-corruption layer: a dedicated adapter module runs on Spring Boot 3.x and communicates with the remaining Boot 2.x modules via message queues (Kafka/RabbitMQ) or HTTP rather than shared classpath. This allows gradual module-by-module migration over multiple sprint cycles without requiring a feature freeze.
Multi-module migration with feature flags (Spring Boot 3.x parent):
# Parent pom.xml — set Boot 3.x globally, but let legacy modules override
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
# Each module declares its own java.version property
# domain-module/pom.xml — migrated, uses Java 21
# legacy-reporting/pom.xml — not yet migrated, still on Java 11 (separate deployment unit)
# Verify no javax.* leakage across module boundaries:
grep -rn "import javax\.\(servlet\|persistence\|validation\|transaction\)" \
--include="*.java" domain/src/ service/src/ api/src/
Migration Effort Estimation by Project Size
One of the most common questions from engineering managers is: how long will this take? The answer depends on project complexity, not just lines of code. The primary cost drivers are: the number of third-party libraries that need Jakarta-compatible updates, the complexity of the Spring Security configuration, the extent of Hibernate 5 API usage, and test coverage quality (which determines how quickly you can validate each migration step).
| Project Profile | Typical Effort | Primary Risk Areas |
|---|---|---|
| Small service: 1–5 modules, <50K LOC, standard deps (JPA, Security, REST) | 3–5 days | javax→jakarta imports, Security config rewrite |
| Medium service: 5–15 modules, 50K–200K LOC, Spring Cloud, Feign, Kafka | 1–2 weeks | Spring Cloud version alignment, Hibernate 6 schema changes, Feign → HTTP Interfaces |
| Large service: 15–50 modules, 200K–1M LOC, custom security, legacy Hibernate criteria | 3–6 weeks | Custom security filters, JPA Criteria API, multi-module classpath isolation, third-party libs without Boot 3.x support |
| Enterprise monolith: 50+ modules, 1M+ LOC, custom extensions, legacy XML Spring config | 2–4 months | XML-to-annotation config migration, domain model namespace conflicts across modules, custom ClassLoader logic, GWT or JSF integration removed in Boot 3.x |
Effort-reduction techniques used by successful migration teams
- Run OpenRewrite before writing a single line — teams that run OpenRewrite first consistently report 30–50% reduction in manual effort. Let the tool do the mechanical changes, then focus human effort on business logic validation.
- Invest in integration tests before starting — teams with >80% integration test coverage complete migrations 2× faster than teams with only unit tests. Unit tests don't catch javax/jakarta runtime classpath conflicts.
- Use a dedicated "migration branch" strategy — create a long-lived migration branch, cherry-pick feature fixes to main, merge migration branch only when all tests pass. Never attempt migration directly on main.
- Upgrade to Spring Boot 2.7.x first — the 2.7.x release was specifically designed as a migration bridge. It deprecates all APIs removed in 3.x and enables the
spring-boot-properties-migrator. Fix all deprecation warnings on 2.7.x before jumping to 3.x. - Allocate a dedicated QA sprint post-migration — plan for one QA sprint focused on verifying production-equivalent load test results and validating UUID column types in your database against the Hibernate 6 mapping defaults.
Key Takeaways
- Spring Boot 2.x is EOL — every day your application runs on it is a security and maintenance liability
- Migrate to Spring Boot 2.7.x first — fix all deprecation warnings there before jumping to 3.x
- The
javax.*tojakarta.*namespace change is pervasive but automatable with OpenRewrite in under 30 minutes for most projects - Spring Security 6 requires migrating from
WebSecurityConfigurerAdaptertoSecurityFilterChain— this is the most labour-intensive step but produces cleaner, more testable security configuration - Hibernate 6 changes UUID mapping and
@Typeusage — validate against your actual database schema in a staging environment before deploying - Java 21 with
spring.threads.virtual.enabled=truedelivers significant throughput gains for I/O-bound services with zero code changes; bewaresynchronizedblock pinning - GraalVM native image becomes available immediately after the migration — 80ms startup and 60 MB memory are achievable for most services
- Spring 6 HTTP Interfaces (
@HttpExchange) replace Feign for simple REST clients — no Spring Cloud dependency needed - Run the
spring-boot-properties-migratorto catch all renamed/removed configuration properties before they silently misconfigure your application - In multi-module projects, migrate the domain module first — all other modules must complete the namespace migration together to avoid javax/jakarta classpath conflicts
- Run OpenRewrite's dry-run mode first, review the full diff, then apply — never run automated migration tools directly on a main branch
Conclusion
Spring Boot 3.x is not just a version bump — it is the most consequential upgrade in the Spring ecosystem since Spring 4.0 introduced annotation-driven programming. The javax.* to jakarta.* change is a one-time migration that unlocks a decade of Jakarta EE progress. Spring Security 6's component-based configuration is simpler to test and maintain than the adapter pattern it replaces. Hibernate 6 brings better performance and cleaner SQL generation. And Java 21's virtual threads turn ordinary synchronous blocking code into a high-concurrency runtime without thread-pool management.
The migration is a meaningful engineering investment — expect one to three weeks for a medium-complexity application, two to four weeks for a large service with complex security and extensive Hibernate usage. But once complete, your application runs on an actively supported, security-patched, high-performance platform with a clear upgrade path through future Spring Boot releases. The teams that have completed this migration consistently report that they wish they had done it sooner.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices