Spring Boot 4 represents a major evolutionary leap for modern Java microservices. Built on top of Spring Framework 7 and Jakarta EE 11, Spring Boot 4 introduces explicit modularity, native API versioning support, declarative HTTP clients, and runtime enhancements powered by Java 17+ baseline requirements.
However, jumping straight into a Spring Boot 4 upgrade without a structured blueprint can trigger build failures, silent security blocks, and broken dependencies. This Spring Boot 4 migration guide walks engineering teams through a zero-downtime, phase-based upgrade strategy.
Step 1: Baseline on Spring Boot 3.5 First
Before changing your major version dependency, upgrade to the latest patch of Spring Boot 3.5.x. Spring Boot 3.5 acts as an essential discovery bridge, surfacing deprecation warnings for methods that are completely removed in version 4.0. Clear all compilation warnings and attach the spring-boot-properties-migrator module to identify renamed properties at startup.
Step 2: Audit System Requirements & Server Baseline
Confirm your environment aligns with Spring Boot 4 infrastructure requirements:
-
JDK: Java 17 minimum (Java 21 or 25 recommended for virtual thread optimization).
-
Servlet Container: Servlet 6.1 baseline (Note: Undertow is dropped due to Servlet 6.1 specs; migrate to Tomcat or Jetty).
-
Build Tooling: Gradle 8.14+ or Maven 3.9+.
Step 3: Address Key Breaking Changes
-
Jackson 3 Integration: Convert legacy
ObjectMapperconfigurations toJsonMapperorXmlMapperbeans. -
Spring Security 7 Enforcement: Ensure deprecated adapters like
WebSecurityConfigurerAdapterare removed. Configure explicitSecurityFilterChainbean definitions for state-changing requests. -
Modular Starters: Update dependency declarations to adopt focused starter POMs, or utilize
spring-boot-starter-classicas a temporary migration bridge.
By systematically tackling deprecations on Spring Boot 3.5 and verifying third-party library compatibility, your team can unlock faster startup times, smaller container footprints, and modern cloud-native capabilities without operational friction.
Exploring Spring Boot 4's new features and their real-world impact
Spring Boot 4 touches nearly every layer of the framework from how jars are packaged, to how nullability is expressed, to how you call other services over HTTP. Below is a deep look at each major feature, what changed from Spring Boot 3.x, and what it looks like in code.
1. Codebase Modularization — Smaller, Focused Jars
In Spring Boot 3.x and earlier, autoconfiguration was largely monolithic: a handful of large starters (spring-boot-starter-web, spring-boot-starter-data-jpa, and so on) pulled in broad, all-in-one classpaths. Spring Boot 4 breaks this apart into smaller, more focused jars, so your application only carries the autoconfiguration classes it actually needs.
Why it matters:
- Smaller jars and leaner classpaths translate into faster startup — meaningful for containers and serverless deployments where cold start time is a cost line item, not just a UX detail.
- Dependency resolution becomes more predictable: fewer surprise transitive classes showing up because one starter quietly pulled in ten others.
- The trade-off is real, though: Spring Boot 3 and Spring Boot 4 dependencies generally shouldn't be mixed on the same classpath. For applications that need a bridge while they migrate, Spring Boot ships a spring-boot-starter-classic (and spring-boot-starter-test-classic for tests) compatibility starter that restores something closer to the old, unified classpath temporarily.
2. JSpecify Null safety — A real standard, not vendor annotations
This is arguably the most consequential change for day-to-day code in Spring Boot 4. For a decade, Spring shipped its own @Nullable / @NonNull / @NonNullApi / @NonNullFields annotations in org.springframework.lang. They were useful as IDE hints but had no formal specification and couldn't express nullability on generics, arrays, or type parameters. As of Spring Framework 7 and Spring Boot 4, those annotations are deprecated in favor of JSpecify — a standard built jointly by Google, JetBrains, and Oracle that every major IDE and static analysis tool can understand natively.
Before (Spring Boot 3.x, package-level annotations):
java
@NonNullApi
@NonNullFields
package com.example.orders;
import org.springframework.lang.Nullable;
Now (Spring Boot 4, JSpecify):
java
@NullMarked
package com.example.orders;
import org.jspecify.annotations.NullMarked;
java
@NullMarked
public class OrderService {
public Order createOrder(String email, @Nullable String promoCode) {
// email is guaranteed non-null by the package's @NullMarked default
// promoCode is explicitly allowed to be null
...
}
}
@NullMarked flips the default for a package, class, or module to "non-null unless marked otherwise" — which matches how most people actually reason about their code. JSpecify also handles nullability on generics, something the old Spring annotations simply couldn't express:
java
// A non-null list that may contain null elements
public void processOptionalTags(List<@Nullable String> tags) { ... }
Migration note: @Nullable on a method's return type is now a type-use annotation, so it moves from above the method signature to directly in front of the return type — a small but easy-to-miss syntax shift when migrating existing code.
3. First-Class API Versioning
Before Spring Boot 4, API versioning had no official answer, teams rolled their own with URL prefixes (/v1/, /v2/), custom HandlerMapping subclasses, or interceptors, and every service ended up doing it slightly differently. Spring Framework 7 adds a version attribute directly to @RequestMapping and its shortcuts (@GetMapping, @PostMapping, etc.), with auto-configuration in Spring Boot 4 for both Spring MVC and WebFlux.
You choose a versioning strategy — URL path segment, request header, query parameter, or media type — with a single property:
properties
# Route by request header
spring.mvc.apiversion.use.header=API-Version
spring.mvc.apiversion.default=1.0
Then declare versions directly on your controller methods:
java
@RestController
@RequestMapping("/accounts")
class AccountController {
@GetMapping(path = "/{id}", version = "1.0+")
AccountV1 getAccountV1(@PathVariable long id) {
return new AccountV1(id, "legacy-name");
}
@GetMapping(path = "/{id}", version = "2.0")
AccountV2 getAccountV2(@PathVariable long id) {
return new AccountV2(id, "name", "email");
}
}
Because the routing strategy lives in configuration rather than in the controller, you can switch strategies without touching controller code—simplifying long-term maintenance in complex systems where APIs and microservices integration form the core architecture. You can also announce a deprecation via the Sunset header effortlessly without modifying a single controller method.
4. Declarative HTTP Service Clients
Spring Framework 6 introduced @HttpExchange interfaces as an alternative to hand-written RestTemplate or WebClient code, but wiring them up still required manually building an HttpServiceProxyFactory and registering a bean for every interface. Spring Boot 4 removes that ceremony with the new @ImportHttpServices annotation, which auto-registers your HTTP interfaces as proxy beans.
Before (Spring Boot 3.x, manual WebClient calls):
java
public User getUser(Long id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User.class)
.block();
}
Now (Spring Boot 4, declarative HTTP interface):
java
@HttpExchange("/users")
public interface UserClient {
@GetExchange("/{id}")
User fetchUser(@PathVariable Long id);
@PostExchange
User createUser(@RequestBody User user);
@GetExchange
List<User> fetchUsersByType(@RequestParam String type);
}
java
@Configuration
@ImportHttpServices(group = "userApi", types = UserClient.class)
class HttpClientConfig {
}
properties
spring.http.client.service.group.userApi.base-url=https://api.example.com
spring.http.client.service.read-timeout=2s
Spring generates the implementation at runtime, so a service layer just injects UserClient and calls client.fetchUser(id) like any other bean. Teams that previously reached for Spring Cloud OpenFeign for this kind of declarative client now get most of the same ergonomics natively, with tighter integration into Spring Security 7 and no extra dependency.
5. Jackson 3 — A bigger change
Spring Boot 4 moves from Jackson 2 to Jackson 3, and this is one of the changes most likely to bite teams that upgrade without reading the fine print. Instead of a single ObjectMapper bean, Spring Boot now auto-configures format-specific mappers: a JsonMapper for JSON and an XmlMapper for XML.
Before (Spring Boot 3.x):
java
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper().registerModule(new JavaTimeModule());
}
Now (Spring Boot 4):
java
@Bean
public JsonMapper jsonMapper() {
return JsonMapper.builder()
.addModule(new JavaTimeModule())
.build();
}
Defining an ObjectMapper bean alone is no longer sufficient to override the auto-configured mapper — you need a JsonMapper or XmlMapper bean instead. There's also a quieter, more dangerous change: Jackson 2's JsonProcessingException extended IOException, but Jackson 3's JacksonException extends RuntimeException. Any catch (IOException e) block that was silently catching Jackson parse errors will stop catching them after the upgrade — a bug that tends to surface in production rather than in a unit test.
For teams that need a faster interim fix, Spring Boot ships a spring.jackson.use-jackson2-defaults property and a spring-boot-jackson2 compatibility starter to ease the transition.
6. Observability, Security, and Messaging (Spring Boot 4.1)
Spring Boot 4.1, released in June 2026, builds on the 4.0 foundation with a smaller but production-focused set of additions:
- Spring gRPC support for writing and testing gRPC servers and clients, including standalone servers backed by Netty or Servlet-based HTTP/2 integration — no more hand-rolling gRPC wiring.
- InetAddressFilter for HTTP clients, letting both reactive and blocking HTTP clients block outgoing requests to specific addresses — a direct mitigation for SSRF attacks in applications that fetch user-supplied URLs.
- Lazy JDBC connection acquisition — a single property that defers acquiring a database connection until it's actually needed, rather than at the start of every transaction, which is a meaningful win under connection-pool pressure.
- A new @RedisListener annotation, giving Redis messaging the same declarative, annotation-driven model that @KafkaListener has had for years.
- Automatic context propagation for @Async methods, plus expanded OpenTelemetry support and SSL bundle support for OTLP exporters.
- Type-safe property paths in Spring Data, replacing stringly-typed property references with something the compiler can actually check.
Comments (0)
Want to join the discussion?
Log In to Comment