Spring Boot 4 Migration What To Expect And How 2 X 3 X Journey
Spring Boot 4 vs. 3: What Actually Changed The migration guides list dozens of changes. Most articles rehash the changelog. This piece focuses on the three changes that will meaningfully alter how you design APIs in 2026: built-in API versioning, null-safe annotations from JSpecify, and Jackson 3 defaults that will silently break your serialization tests. 1. What This Release Actually Is Spring Boot 4 and Spring Framework 7 went GA on November 20, 2025. The marketing framing is “next generation,” which typically means modest improvement. This time it’s more accurate.
The release modularises the entire Spring Boot codebase into 70+ focused JARs, mandates Jackson 3 as the JSON library, and ships three significant additions that change how you’ll write Java APIs going forward. The Java baseline stays at 17, which is the right call — as Juergen Hoeller told InfoQ, “the current industry consensus is clearly around a Java 17 baseline.” First-class support for Java 25 is included, meaning AOT compilation and GraalVM native images are tested on Java 25 and take advantage of its capabilities.
The Jakarta EE baseline moves to 11, which means Hibernate must upgrade to 7.x, Bean Validation to 3.1, and Tomcat to 11. If you have any javax.* imports that somehow survived Boot 3, they will not survive Boot 4. The other removals are concrete: JUnit 4 is gone, Undertow is removed, and all Boot 3 deprecations have been cleared. The recommended upgrade path is to go through Spring Boot 3.5 first, eliminate every deprecation warning, then migrate to 4.0.
Any deprecated API that made it to 3.5 is fully deleted in 4.0, with no fallback. The right migration path Spring Boot 3.5 → fix every deprecation warning → Spring Boot 4.0. Do not jump directly from 3.2 or 3.3. The Boot team aligned 3.5 specifically to be a clean stepping stone. Any deprecated API that survived into 3.5 is absent from 4.0 without exception. 2.
API Versioning: The Feature That Changes How You Write Controllers Before Spring Boot 4, versioning a REST API in Spring required one of three hacks: URL path duplication (/api/v1/users and /api/v2/users as separate controller classes), custom request interceptors that parsed a header and routed accordingly, or content negotiation overloading. None of these were framework-first concepts; they were conventions imposed on top of Spring’s request mapping infrastructure. Spring Framework 7 makes versioning a first-class part of request mapping.
The new version attribute is available on @GetMapping , @PostMapping , and all other mapping annotations. A single controller class can now serve multiple versions of the same endpoint with no duplication of the routing path. @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void configureApiVersioning(ApiVersionConfigurer configurer) { configurer .useRequestHeader("API-Version") // resolve from header .setVersionRequired(true) // reject unversioned requests .addSupportedVersions("1.0", "1.1", "2.0"); } } @RestController @RequestMapping("/accounts") public class AccountController { // Serves requests with API-Version: 1.0 @GetMapping(version = "1.0") public AccountV1 getAccountV1() { ...
} // Serves 1.1 and later (until a higher version takes over) @GetMapping(version = "1.1+") public AccountV2 getAccountV1Plus() { ... } // Serves requests with API-Version: 2.0 @GetMapping(version = "2.0") public AccountV3 getAccountV2() { ... } } The four versioning strategies The ApiVersionConfigurer supports four resolution strategies, selectable per application. Path-segment versioning (usePathSegment(1) ) reads the version from a specific URL path index. Header versioning (useRequestHeader("API-Version") ) is the default recommended approach for new APIs. Query parameter versioning (useQueryParam("version") ) is available for teams that prefer URL-only versioning.
Media type versioning (useMediaTypeParameter("v") ) supports Accept: application/vnd.api+json;v=2.0 -style content negotiation. Additionally, these can be combined so the same application accepts multiple resolution strategies simultaneously. Built-in deprecation via RFC 9745 and RFC 8594 The part of Spring’s API versioning that will genuinely save operational pain is the built-in deprecation handling. The StandardApiVersionDeprecationHandler can automatically append Deprecation , Sunset , and Link headers to responses for deprecated versions — compliant with RFC 9745 and RFC 8594.
Before this, teams either implemented this manually in each controller or (more often) skipped it entirely and left clients with no signal that a version was being retired. @Override public void configureApiVersioning(ApiVersionConfigurer configurer) { StandardApiVersionDeprecationHandler handler = new StandardApiVersionDeprecationHandler(); // v1.0 sunset date and link to migration docs handler.addDeprecation("1.0", ZonedDateTime.of(2026, 9, 1, 0, 0, 0, 0, ZoneId.of("UTC")), URI.create("https://docs.example.com/api/migration")); configurer .useRequestHeader("API-Version") .setVersionDeprecationHandler(handler); } // Response for v1.0 requests will now include: // Deprecation: @1735689600 // Sunset: "2026-09-01T00:00:00Z" // Link: <https://docs.example.com/api/migr 3.
JSpecify: Null Safety That Finally Has Ecosystem Weight Java’s null problem is old news. Tony Hoare called the null reference his “billion dollar mistake” in 2009. Java has had Optional since Java 8, and various null-annotation libraries for longer. The problem has never been lack of tools — it’s been fragmentation. JetBrains had their own @Nullable . Android had theirs. Spring had org.springframework.lang.@Nullable since 2017. JSR-305 stalled. No two tools agreed on semantics.
JSpecify is a Google-led collaborative effort — involving JetBrains, Oracle, Uber, Meta, Sonar, and Broadcom — to finally standardise nullability annotations. Version 1.0 shipped in July 2024. Spring Boot 4 adopts it portfolio-wide, deprecating Spring’s own org.springframework.lang annotations in its favour. IntelliJ IDEA 2025.3 ships first-class JSpecify support, automatically preferring JSpecify annotations over JetBrains’ own when both are on the classpath. How it actually works JSpecify’s design is deliberately simple.
Rather than annotating every non-null type (which would be the vast majority of your codebase), you declare a package as @NullMarked in a package-info.java file. Within that zone, everything is non-null by default. You annotate only the exceptions — the places where null is genuinely possible — with @Nullable .
// src/main/java/com/example/orders/package-info.java @NullMarked package com.example.orders; import org.jspecify.annotations.NullMarked; // Within the @NullMarked package — everything is non-null by default @Service public class OrderService { // email is guaranteed non-null — no annotation needed // promoCode explicitly allows null — @Nullable marks the exception public Order createOrder(String email, @Nullable String promoCode) { // IDE and NullAway will flag any unchecked use of promoCode ...
} // Return type explicitly nullable — caller must handle null public @Nullable Order findByReference(String reference) { return repository.findByRef(reference); // may return null } // Generic nullability — the List is non-null, but elements may be null public List<@Nullable String> getSurveyResponses() { ... } } The key difference from Optional : JSpecify is a static annotation, not a runtime wrapper. It carries zero runtime overhead. The nullability contract exists in the type system and is checked by tooling, not by the JVM.
Optional is a runtime abstraction that wraps a value; nothing stops you from calling .get() without checking. JSpecify makes the contract visible to your IDE and, more importantly, to NullAway — an Error Prone plugin that turns annotation violations into build failures. Compile-time enforcement with NullAway IDE warnings are helpful but ignorable. NullAway, configured in your Gradle or Maven build, makes them not ignorable. When a @Nullable return value is used without a null check inside a @NullMarked package, NullAway fails the build.
The error message is explicit: [NullAway] dereferenced expression token is @Nullable . JDK 17 users can use it with a JDK 21 toolchain; native support for JDK 17 compilation may follow. On Java 25, -Xnullity flag support is in discussion. What changes for your code The practical impact depends entirely on how much you adopt. You can do nothing — JSpecify annotations in the Spring codebase are purely additive. Your code compiles and runs unchanged.
The upside is that you immediately get better IDE null-flow analysis on Spring’s own APIs, because the framework code now carries explicit nullability contracts. If you want compile-time enforcement in your own code, the migration path is gradual by design. You add @NullMarked one package at a time. Any package without it behaves exactly as Java always has — unspecified nullability. As Marco Molteni’s detailed analysis notes, the friction points are honest: on a large codebase, adding @NullMarked to a package can surface thousands of warnings at once.
@NullUnmarked at the class or method level is the escape hatch for legacy code you can’t immediately fix — but it requires discipline to prevent it from becoming a permanent feature. 4. Jackson 3: The Breaking Change That Will Catch You Off Guard Of the three changes this article covers, Jackson 3 is the one most likely to break your application silently.
Not because the migration is technically complex — most of it is mechanical and automatable with OpenRewrite — but because two of its changed defaults will affect the shape of your JSON output without any compilation error to warn you first. The package and group ID rename The most visible change is the package relocation. Every Jackson class that previously lived under com.fasterxml.jackson now lives under tools.jackson . The Maven group ID changes accordingly.
The one intentional exception is jackson-annotations — annotations like @JsonProperty , @JsonIgnore , and @JsonView remain under com.fasterxml.jackson.annotation for backward compatibility and are shared between Jackson 2 and 3. Spring Boot 3 · Jackson 2 // pom.xml / build.gradle com.fasterxml.jackson.core :jackson-databind // Java import import com.fasterxml.jackson .databind.ObjectMapper; Spring Boot 4 · Jackson 3 // pom.xml / build.gradle tools.jackson.core :jackson-databind // Java import import tools.jackson .databind.json.JsonMapper; Note the class rename too: the recommended entry point is now JsonMapper rather than ObjectMapper .
JsonMapper extends ObjectMapper but offers an immutable builder API and comes pre-configured with sensible defaults for Java 17+ — you no longer need to register jackson-datatype-jsr310 separately for LocalDateTime support. Spring Boot 4’s equivalent configuration hook is JsonMapperBuilderCustomizer , replacing Jackson2ObjectMapperBuilderCustomizer . The two default changes that will break your tests These are the changes that require no import fix and generate no compile error, but will silently alter your JSON output the moment you upgrade: Default 1: Dates are no longer timestamps Jackson 2 default: WRITE_DATES_AS_TIMESTAMPS = true .
Result: {"createdAt": 1699257000000} Jackson 3 default: WRITE_DATES_AS_TIMESTAMPS = false . Result: {"createdAt": "2025-11-06T05:30:00"} ISO-8601 strings are more readable and easier for frontend frameworks to parse. But if your clients or tests assert on Unix timestamp format, they will fail silently in production after the upgrade. Audit every field of type LocalDate , LocalDateTime , ZonedDateTime , Instant , and OffsetDateTime in your API responses. Default 2: Properties are sorted alphabetically Jackson 2 default: SORT_PROPERTIES_ALPHABETICALLY = false (insertion order). Jackson 3 default: SORT_PROPERTIES_ALPHABETICALLY = true .
If your API clients parse JSON by field order, or if your snapshot tests assert on specific property ordering, they will start failing. JSON spec does not guarantee field order, so strict clients are technically wrong — but many exist in practice. OpenAPI-generated clients typically handle this correctly; bespoke parsing code sometimes does not. The exception change that will hit your catch blocks Jackson 2’s JsonProcessingException extended IOException . Jackson 3’s JacksonException extends RuntimeException .
Any catch (IOException e) block that was silently catching Jackson serialization errors will no longer catch them. The exception propagates unchecked. In production, this means a serialization failure that previously showed up as a handled error response now becomes an unhandled exception.
// Spring Boot 3 — catch (IOException) also caught Jackson errors try { objectMapper.readValue(json, MyClass.class); } catch (IOException e) { // <- caught JsonProcessingException too handleError(e); } // Spring Boot 4 — must catch JacksonException explicitly try { jsonMapper.readValue(json, MyClass.class); } catch (JacksonException e) { // <- unchecked, extends RuntimeException handleError(e); } // Or catch both during a transition period: // } catch (IOException | JacksonException e) { ... } The coexistence path Spring Boot 4 ships with Jackson 2 compatibility support specifically to enable incremental migration.
You can temporarily add spring-boot-jackson2 instead of spring-boot-starter-jackson and use spring.jackson.use-jackson2-defaults: true in your configuration to preserve Jackson 2 behaviour while you migrate. The Spring team is explicit that this is a bridge, not a long-term option: Jackson 2 auto-configuration is deprecated in Boot 4 and will be removed in a future release (expected 7.1 timeline). Spring Boot 4 Migration Effort by Change Area 5.
Migration Reality: What to Do Before Upgrading A practical checklist, not a complete migration guide (use the official one for that): OpenRewrite covers most of the mechanical work The OpenRewrite recipe org.openrewrite.java.jackson.UpgradeJackson_2_3 handles the Jackson package and group ID migration automatically. The recipe Migrate_To_Jakarta_EE_10 handles remaining javax.* imports. What OpenRewrite cannot do: catch the date serialization default change, find catch (IOException) blocks that silently swallowed Jackson errors, or reason about client contract expectations. Those require manual review. 6.
What We Have Learned Spring Boot 4 is not a routine major version bump. The three changes covered here are each genuinely structural: API versioning becomes a first-class framework concept rather than a convention bolted onto @RequestMapping . JSpecify gives Java a standardised null contract specification with the ecosystem weight to finally matter — the Spring team, JetBrains, Google, Uber, and Oracle all behind the same annotations is different from the previous fragmented landscape. Jackson 3 changes the JSON library your application uses at its foundation.
Of the three, Jackson 3 is the most likely to cause production incidents on teams that don’t read the fine print. The package rename is mechanical and OpenRewrite handles it. The date serialization default and property ordering default are silent — no compiler warning, no deprecation notice. They change the shape of your JSON output. Clients that parse Unix timestamps or depend on field ordering will break. Find them in your contract tests before they find themselves in production. JSpecify is the most underrated change.
The fragmentation of null annotation libraries in Java has been a real cost — tools that don’t interoperate, conventions that differ per library, and IDE support that varies by which annotation you chose. Having the Spring portfolio, Kotlin’s nullability system, and IntelliJ IDEA’s flow analysis all reading from the same specification is a meaningful baseline improvement even if you never write a single @NullMarked annotation yourself. If you do adopt it, start package by package and treat @NullUnmarked as a temporary escape hatch with a deadline, not a permanent exception.
And API versioning — if you’ve been putting this off because every solution felt like a hack on top of Spring rather than a feature of it, that excuse is now gone. The version attribute on mapping annotations, the StandardApiVersionDeprecationHandler for RFC-compliant sunset headers, and ApiVersionInserter for clients add up to a versioning system you can actually maintain. The right time to design it into a new service is before version 2 exists, not after.
People Also Asked
- SpringBoot4vs.3: What Actually Changed - Java Code Geeks
- Initializr generatesspringbootproject with just what you need to start...
- GitHub -spring-projects/spring-ai: An Application Framework for AI...
- SpringBootTutorial - GeeksforGeeks
- SpringInitializr
- Documentation for thespringGenerator | OpenAPI Generator
- SpringBoot4Made Me Rethink My Architecture... | Medium
SpringBoot4vs.3: What Actually Changed - Java Code Geeks?
The error message is explicit: [NullAway] dereferenced expression token is @Nullable . JDK 17 users can use it with a JDK 21 toolchain; native support for JDK 17 compilation may follow. On Java 25, -Xnullity flag support is in discussion. What changes for your code The practical impact depends entirely on how much you adopt. You can do nothing — JSpecify annotations in the Spring codebase are pure...
Initializr generatesspringbootproject with just what you need to start...?
Rather than annotating every non-null type (which would be the vast majority of your codebase), you declare a package as @NullMarked in a package-info.java file. Within that zone, everything is non-null by default. You annotate only the exceptions — the places where null is genuinely possible — with @Nullable .
GitHub -spring-projects/spring-ai: An Application Framework for AI...?
API Versioning: The Feature That Changes How You Write Controllers Before Spring Boot 4, versioning a REST API in Spring required one of three hacks: URL path duplication (/api/v1/users and /api/v2/users as separate controller classes), custom request interceptors that parsed a header and routed accordingly, or content negotiation overloading. None of these were framework-first concepts; they were...
SpringBootTutorial - GeeksforGeeks?
Any deprecated API that made it to 3.5 is fully deleted in 4.0, with no fallback. The right migration path Spring Boot 3.5 → fix every deprecation warning → Spring Boot 4.0. Do not jump directly from 3.2 or 3.3. The Boot team aligned 3.5 specifically to be a clean stepping stone. Any deprecated API that survived into 3.5 is absent from 4.0 without exception. 2.
SpringInitializr?
@NullUnmarked at the class or method level is the escape hatch for legacy code you can’t immediately fix — but it requires discipline to prevent it from becoming a permanent feature. 4. Jackson 3: The Breaking Change That Will Catch You Off Guard Of the three changes this article covers, Jackson 3 is the one most likely to break your application silently.