Spring Boot 4.0: Everything That Changed (Complete Guide)

Spring Boot 4.0 was released on November 20, 2025. It is built on Spring Framework 7 and represents the most significant shift in the Spring ecosystem since the Jakarta EE migration in Spring Boot 3. The headline change is full modularisation — the single spring-boot-autoconfigure JAR has been split into 70+ granular modules. But that is just the start.

This guide covers every change that matters, what breaks on upgrade, and what is genuinely new and useful.


What Is the Baseline?

Spring Boot 4.0 requires:

  • Java 17 minimum (Java 21 strongly recommended; Java 25 is the new LTS and recommended for production)
  • Jakarta EE 11 (up from EE 10 in Spring Boot 3)
  • Spring Framework 7
  • Gradle 8.4+ or Maven 3.9.9+

If you are still on Spring Boot 2.x, the jump to 4.0 is significant. The migration checklist article covers the step-by-step path from 2.x → 3.x → 4.x.


Modularisation: The Biggest Structural Change

In Spring Boot 3.x and earlier, virtually all auto-configuration lived in a single JAR: spring-boot-autoconfigure. Every application loaded it, even if it only used a fraction of the configurations inside.

Spring Boot 4 breaks this into 70+ individual auto-configuration modules:

spring-boot-autoconfigure-jdbc
spring-boot-autoconfigure-jpa
spring-boot-autoconfigure-web
spring-boot-autoconfigure-security
spring-boot-autoconfigure-cache
spring-boot-autoconfigure-actuator
... (70+ more)

Each starter (spring-boot-starter-data-jpa, spring-boot-starter-web, etc.) now pulls in only the auto-configuration modules it needs.

What this means in practice:

  • Smaller native images: GraalVM only processes the modules your app uses. Build times drop and binary sizes shrink significantly.
  • Faster startup: less code to process on startup, even on the JVM.
  • Custom starters: if you maintain internal starters, they need updating — the import mechanism changed from spring.factories to AutoConfiguration.imports (which was already required in Boot 3, but now the module structure changes what you reference).

What breaks:

If you have custom auto-configuration referencing internal Boot classes, some class paths changed. The spring-boot-autoconfigure JAR still exists as a compatibility shim but will be deprecated. Migrate custom starters to reference specific module JARs.


JSpecify Null-Safety: Catch NullPointerExceptions at Build Time

Spring Framework 7 ships with JSpecify null-safety annotations across the entire codebase. @NonNull and @Nullable are now on every method signature and parameter.

This matters because IDEs and static analysis tools (IntelliJ, Eclipse, Checkstyle, ErrorProne) can now warn you at development time when you pass a potentially null value to a method that does not accept null.

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;

@Service
public class UserService {

    // Compiler/IDE warns if caller passes null for userId
    public User findById(@NonNull Long userId) {
        return userRepository.findById(userId).orElseThrow();
    }

    // Return type explicitly may be null
    public @Nullable User findByEmail(String email) {
        return userRepository.findByEmail(email).orElse(null);
    }
}

Practical impact: You will start seeing new IDE warnings on existing code when you upgrade. Most will be informational. Treat them as a prioritised list of places where your code might throw a NullPointerException at runtime. Fix the ones that matter; suppress the ones you have deliberately handled.

This does not change runtime behaviour — it is compile-time and IDE tooling only.


Declarative HTTP Clients

Spring Framework 7 introduces @HttpServiceClient, a declarative HTTP client annotation similar to how Spring Data repositories work. You write an interface; Spring generates the implementation.

import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpServiceClient;

@HttpServiceClient(url = "${github.api.base-url}")
public interface GitHubClient {

    @GetExchange("/users/{username}")
    GitHubUser getUser(@PathVariable String username);

    @GetExchange("/repos/{owner}/{repo}/issues")
    List<GitHubIssue> getIssues(
        @PathVariable String owner,
        @PathVariable String repo
    );
}
// In your application — inject and use directly
@Service
public class GitHubService {

    private final GitHubClient gitHubClient;

    public GitHubService(GitHubClient gitHubClient) {
        this.gitHubClient = gitHubClient;
    }

    public GitHubUser getUserProfile(String username) {
        return gitHubClient.getUser(username);
    }
}
# application.yml
github:
  api:
    base-url: https://api.github.com

Spring Boot 4 auto-configures a RestClient (the modern replacement for RestTemplate) and wires it into the @HttpServiceClient beans automatically. No manual WebClient.Builder or RestClient.Builder setup needed.

Why this matters: The old approach required either RestTemplate (deprecated and verbose) or manually configuring WebClient/RestClient and writing boilerplate request methods. Declarative HTTP clients eliminate that boilerplate entirely.


Built-In API Versioning

REST API versioning has historically required either URL path segments (/v1/users), custom request headers, or content negotiation — all needing hand-rolled filter or interceptor code. Spring Framework 7 adds first-class API versioning support directly in @RequestMapping.

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.GetMapping;

@RestController
@RequestMapping(path = "/users", version = "1")
public class UserControllerV1 {

    @GetMapping("/{id}")
    public UserResponseV1 getUser(@PathVariable Long id) {
        // v1 response format
    }
}

@RestController
@RequestMapping(path = "/users", version = "2")
public class UserControllerV2 {

    @GetMapping("/{id}")
    public UserResponseV2 getUser(@PathVariable Long id) {
        // v2 response format — different fields, different structure
    }
}

Configure the versioning strategy:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void configureApiVersioning(ApiVersioningConfigurer configurer) {
        configurer
            .useRequestHeader("X-API-Version")  // header-based
            // OR:
            // .useRequestParameter("version")  // query param: /users/1?version=2
            // OR:
            // .usePathPrefix()                 // URL path: /v1/users/1
            .defaultVersion("1");
    }
}

Clients send X-API-Version: 2 in their request header and get routed to the v2 controller automatically. No custom filter, no reflection tricks — it is handled by the framework.


Jackson 3: The Breaking Change Most Teams Will Hit

Spring Boot 4 ships with Jackson 3, replacing Jackson 2. Most of the API is compatible, but there is one breaking change that affects almost every application that catches JsonProcessingException:

// Jackson 2 — JacksonException extends IOException
try {
    String json = objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {  // was a checked exception (IOException subtype)
    // handled as checked exception
}

// Jackson 3 — JacksonException extends RuntimeException
// The catch block above still compiles but is now catching a RuntimeException
// If you have code like this, it will still work — but the behaviour changes:

The important implication: methods that previously declared throws JsonProcessingException in their signature no longer need to. More significantly, if you have try-catch blocks specifically handling JsonProcessingException as a checked exception, review them — they may be silently swallowing runtime exceptions that should propagate.

Additionally:

  • The @JsonDeserialize/@JsonSerialize API has minor changes in Jackson 3
  • TreeNode interface was updated
  • Some deprecated Jackson 2 methods were removed

The Spring Boot migration guide includes a Jackson 2 → 3 compatibility layer that handles the most common cases, but you should audit your custom serialisers and deserialisers.


Spring Security 7: What Changed

Spring Security 7 ships as part of the Boot 4 train. Three changes will affect most projects.

authorizeRequests() Removed

This was deprecated in Spring Security 5.8 and is gone in 7. Use authorizeHttpRequests():

// Old (Spring Security 5.x and below — REMOVED in 7)
http.authorizeRequests()
    .antMatchers("/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated();

// Correct (Spring Security 6+, required in 7)
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated()
);

CSRF Enabled by Default for APIs

Spring Security 7 enables CSRF protection for REST APIs by default. If your API is stateless and uses JWT (not cookies for auth), you need to explicitly disable it:

http.csrf(csrf -> csrf.disable());  // safe for JWT/Bearer token APIs

If your API uses session cookies for authentication, keep CSRF enabled and ensure your frontend includes the CSRF token.

Built-In MFA Support

Spring Security 7 adds native multi-factor authentication support. Previously, MFA required significant custom code. Now:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/mfa/**").authenticated()
                .anyRequest().fullyAuthenticated()
            )
            .formLogin(Customizer.withDefaults())
            .mfa(mfa -> mfa
                .totpAuthenticationPage("/mfa/totp")
                .allowRememberDevice(true)
            );
        return http.build();
    }
}

Spring Retry in the Framework Core

Spring Retry annotations have moved from the separate spring-retry library into Spring Framework 7 core. No additional dependency needed.

// Spring Boot 4 — @Retryable is in spring-framework core
@Service
public class ExternalApiClient {

    @Retryable(
        retryFor = {HttpServerErrorException.class},
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public String callExternalApi(String endpoint) {
        return restClient.get().uri(endpoint).retrieve().body(String.class);
    }

    @Recover
    public String fallback(HttpServerErrorException e, String endpoint) {
        return "Service temporarily unavailable";
    }
}

The new @ConcurrencyLimit annotation (also in Framework 7 core) limits how many threads can concurrently execute a method — useful for protecting external services without a full circuit breaker setup:

@ConcurrencyLimit(5)  // max 5 concurrent calls to this method
public SearchResult searchExternalIndex(String query) {
    return searchClient.search(query);
}

Undertow Dropped

Undertow does not support Servlet 6.1, which is required by Spring Boot 4. It has been removed.

If your pom.xml or build.gradle explicitly excludes Tomcat and includes Undertow, this breaks on upgrade:

<!-- This will fail in Spring Boot 4 — Undertow is no longer available -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId> <!-- REMOVED -->
</dependency>

Switch to Jetty instead:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jetty</artifactId>
</dependency>

Unified Observability Starter

Previously, setting up full observability (metrics + traces + logs) in Spring Boot required assembling multiple Micrometer, OpenTelemetry, and bridge dependencies manually. Spring Boot 4 ships a single starter:

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

This covers:

  • Metrics (Micrometer → OTLP)
  • Distributed traces (Micrometer Tracing → OTLP)
  • Log correlation (trace IDs injected into log output automatically)

Configure the OTEL collector endpoint:

management:
  otlp:
    metrics:
      export:
        url: http://otel-collector:4318/v1/metrics
    tracing:
      endpoint: http://otel-collector:4318/v1/traces
  tracing:
    sampling:
      probability: 0.1  # sample 10% of traces in production

JUnit 4 and Jackson 2 Deprecated

Both are deprecated in Spring Boot 4 and will be removed in 4.1 or later.

JUnit 4 → JUnit 5 migration is usually straightforward:

// JUnit 4
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
public class UserServiceTest {
    @Test
    public void shouldFindUser() { ... }
}

// JUnit 5 (required approach)
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class UserServiceTest {
    @Test
    void shouldFindUser() { ... }
}

Run OpenRewrite’s JUnit 4 → 5 migration recipe to automate most of this:

./mvnw rewrite:run -Drewrite.recipeArtifactCoordinates=\
  org.openrewrite.recipe:rewrite-testing-frameworks:LATEST \
  -Drewrite.activeRecipes=org.openrewrite.java.testing.junit5.JUnit4to5Migration

Spring Boot 4 Quick Upgrade Checklist

flowchart TD
    A[Start upgrade] --> B[Upgrade to Spring Boot 3.5 first]
    B --> C[Fix all deprecation warnings]
    C --> D[Switch to JUnit 5]
    D --> E[Update authorizeRequests to authorizeHttpRequests]
    E --> F[Check Jackson exception handling]
    F --> G[Remove Undertow if used]
    G --> H[Bump to Spring Boot 4.0]
    H --> I[Run tests]
    I --> J{Tests pass?}
    J -->|Yes| K[Check custom starters for module changes]
    J -->|No| L[Fix failures, check migration guide]
    L --> I
    K --> M[Review new CSRF defaults]
    M --> N[Done]
ChangeImpactAction
spring-boot-autoconfigure modularisedCustom startersUpdate imports to specific modules
Jackson 3JacksonException is now RuntimeExceptionAudit catch blocks
Undertow removedApps using UndertowSwitch to Jetty
authorizeRequests() removedAll Security configsChange to authorizeHttpRequests()
CSRF enabled for APIsStateless REST APIsExplicitly disable if using JWT
JUnit 4 deprecatedTest codeMigrate to JUnit 5
Spring Retry in corespring-retry dependencyRemove explicit spring-retry dep
JSpecify annotationsIDE warningsReview and fix high-priority nulls

What Did Not Change

Not everything broke. The following continue to work exactly as before:

  • application.properties / application.yml — same format, same property keys
  • @RestController, @Service, @Repository, @Component — unchanged
  • Spring Data repositories — same interface-based approach
  • @SpringBootTest, @WebMvcTest, @DataJpaTest — same test slices
  • Virtual Threads (spring.threads.virtual.enabled=true) — already in 3.2, unchanged
  • GraalVM native image support — available since 3.0, improved in 4.0
  • Profiles (@Profile, spring.profiles.active) — unchanged
  • All Actuator endpoints — same paths, same configuration

Spring Boot 4.0 is a significant upgrade but a manageable one if you are already on 3.x. The modularisation improves startup time, native image size, and build performance. JSpecify gives you better null safety tooling. The Security 7 changes are mostly removals of long-deprecated APIs. If you have been keeping up with deprecation warnings in 3.x, most of these will not surprise you.

The full migration guide is in the next article: Spring Boot 2.x → 3.x → 4.x Migration Checklist.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.