Spring Boot 4 Tutorial: What’s New in 4.0 and 4.1 (Examples)

Spring Boot 4 is the major release built on Spring Framework 7, Jakarta EE 11 and Jackson 3, with Java 17 as the minimum. This tutorial builds one Spring Boot 4.1.1 app that shows the modular starters, JSpecify null-safety with NullAway, API versioning, HTTP service clients with @Retryable, Jackson 3, MockMvcTester and RestTestClient tests, virtual threads, OpenTelemetry traces and the 4.1 additions, and ends with an upgrade checklist from Spring Boot 3.5.

Side-by-side comparison of Spring Boot 3.5 with one spring-boot-autoconfigure jar and one test starter, and Spring Boot 4 where spring-boot-starter-webmvc pulls spring-boot-webmvc, spring-boot-tomcat, spring-boot-jackson and spring-boot-http-converter, spring-boot-starter-flyway pulls spring-boot-flyway, and each technology has its own test starter

Spring Boot 4 is the major release of Spring Boot that is built on Spring Framework 7, Jakarta EE 11, Jackson 3 and JUnit 6, keeps Java 17 as the minimum and supports Java 25. Spring Boot 4.0 came out in November 2025 and Spring Boot 4.1 in June 2026. The current release is 4.1.1.

We use Spring Boot 4 for every new Spring project and as the upgrade target for Spring Boot 3.5 apps, because the free (OSS) support for 3.5 ended on June 30, 2026.

The following example shows the main Spring Boot 4 features in one place. Each line comes from the pantry API that we build in this tutorial, and the comment shows what the line does.

// pom.xml: spring-boot-starter-webmvc replaces spring-boot-starter-web
@ImportHttpServices(group = "prices", types = PriceClient.class)                    // registers the interface as an HTTP client bean
PantryItem kiwi = pantryService.find("kiwi");                                       // null; find() returns a JSpecify 

@Nullable type
@GetMapping(path = "/{name}", version = "2")                                        // API-Version: 2 returns 

{"name":"apple","quantity":5,"bestBefore":"2026-10-12"}
@Retryable(includes = HttpServerErrorException.class, maxRetries = 2, delay = 100)  // retry support from Spring Framework 7

String json = jsonMapper.writeValueAsString(apple);                                 // Jackson 3 from the tools.jackson packages
assertThat(mvc.get().uri("/pantry/apple")).hasStatusOk();                           // MockMvcTester with AssertJ

Notice that JSpecify annotations, API versioning, HTTP service clients and retries all come with Spring Framework 7, so they need no extra dependency such as Spring Retry, and Spring Boot 4 configures them from application.properties.

In the next sections, we go through each feature with the working code and its output. We also cover the Spring Boot 4.1 additions, the removed features and a short checklist for the upgrade from Spring Boot 3.5.

1. What’s New in Spring Boot 4

Spring Boot 4 raises the baseline of every library under it, so most of the changes come from the new major versions of Spring Framework, Jakarta EE, Jackson and JUnit. The Java baseline stays at 17, so an app that runs on Spring Boot 3.5 with Java 17 or 21 does not need a JDK upgrade.

AreaSpring Boot 3.5Spring Boot 4.1.1
Java17 to 2517 to 26 (Java 25 is the current LTS)
Spring Framework6.27.0.9
Jakarta EE10 (Servlet 6.0)11 (Servlet 6.1)
Embedded serversTomcat 10.1, Jetty 12.0, UndertowTomcat 11.0, Jetty 12.1 (Undertow removed)
JSONJackson 2 (com.fasterxml.jackson)Jackson 3.1.5 (tools.jackson)
TestingJUnit 5JUnit 6.0.3
JPAHibernate 6.6Hibernate 7.4.5
Spring Security6.57.1.1
Kotlin and GraalVMKotlin 1.9+, GraalVM 22.3+Kotlin 2.2+, GraalVM 25+
Free support endsJune 30, 20264.0: December 31, 2026, 4.1: July 31, 2027

Besides auto-configuring the Spring Framework 7 features, such as RestTestClient and the Jackson 3 support, Spring Boot 4 brings two changes of its own, the modular starters and the new OpenTelemetry starter. Spring Framework 7 also adds JmsClient, a fluent alternative to JmsTemplate, and the BeanRegistrar contract for registering beans in code.

2. Spring Boot 4 Example Project Setup

The following example is a small pantry API built with Spring Boot 4.1.1 and Java 25. It returns pantry items such as “apple” with a quantity of 5 and gets prices from a supplier API through an HTTP service client. It also sends traces to Jaeger. The complete project is on GitHub, with 14 tests.

We can create the same skeleton on start.spring.io by picking Spring Boot 4.1.1, Java 25 and the “Spring Web” dependency. The generated pom.xml already uses the new starter names spring-boot-starter-webmvc and spring-boot-starter-webmvc-test.

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>        <!-- Spring MVC, Tomcat, Jackson 3 -->
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-restclient</artifactId>    <!-- RestClient, HTTP service clients -->
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-opentelemetry</artifactId> <!-- traces over OTLP -->
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>   <!-- MockMvcTester, RestTestClient -->
    <scope>test</scope>
  </dependency>
</dependencies>

The app has a PantryController with two versions of the same endpoint, a PantryService that holds the items in a map, a PriceClient interface for the supplier API and a PriceService that retries failed supplier calls. A SupplierController in the same app plays the supplier, so the project runs without any other service.

2.1. Modular Starters and Renamed Starters

In Spring Boot 3.5, one large spring-boot-autoconfigure jar holds the auto-configuration for every technology, and Spring Boot turns a feature on as soon as its library is on the classpath. Spring Boot 4 splits that jar into small modules, one per technology, named spring-boot-<technology>. Each module has a starter named spring-boot-starter-<technology> and a test starter named spring-boot-starter-<technology>-test.

Side-by-side comparison of Spring Boot 3.5 with one spring-boot-autoconfigure jar and one test starter, and Spring Boot 4 where spring-boot-starter-webmvc pulls spring-boot-webmvc, spring-boot-tomcat, spring-boot-jackson and spring-boot-http-converter, spring-boot-starter-flyway pulls spring-boot-flyway, and each technology has its own test starter
In Spring Boot 4, a library on the classpath does nothing until the matching module is there, so we add the starter for every technology we use.

The practical consequence is that a dependency on a plain library, such as flyway-core, no longer activates its Spring Boot support. We need spring-boot-starter-flyway instead, otherwise the migrations never run and the app starts without an error. Most other changes are renames.

Spring Boot 3.5Spring Boot 4Note
spring-boot-starter-webspring-boot-starter-webmvcThe old name still works in 4.1.1 but is deprecated
spring-boot-starter-aopspring-boot-starter-aspectjNo 4.x release of the old name, so the build fails with “dependency.version is missing”
spring-boot-starter-oauth2-clientspring-boot-starter-security-oauth2-clientSame for -resource-server and -authorization-server
spring-boot-starter-web-servicesspring-boot-starter-webservicesDeprecated old name
flyway-core or liquibase-core onlyspring-boot-starter-flyway or spring-boot-starter-liquibaseThe module holds the auto-configuration
spring-boot-starter-testspring-boot-starter-<technology>-testEach test starter brings spring-boot-starter-test
spring-boot-starter-batchspring-boot-starter-batch-jdbcThe plain batch starter keeps metadata in memory
spring-boot-starter-tomcat (war)spring-boot-starter-tomcat-runtimeFor war files deployed to Tomcat

The test starters matter more than they look. For example, @WithMockUser from Spring Security works only with spring-boot-starter-security-test on the test classpath. Some starters also bring less than before; the Spring Boot Thymeleaf starter, for example, no longer brings Spring MVC.

If an upgrade fails with many missing classes, we can add spring-boot-starter-classic and spring-boot-starter-test-classic. They put all modules on the classpath, like Spring Boot 3.5, so we can fix the code first and pick the focused starters later.

3. Null Safety with JSpecify Annotations

A NullPointerException happens when code calls a method on a value that is null, and in Java the type alone never tells us whether a value can be null. Spring Framework 7 and Spring Boot 4 annotate their public APIs with JSpecify annotations, which replace the deprecated org.springframework.lang.Nullable and @NonNullApi. Our own code can use the same annotations, and jspecify 1.0.1 is already on the classpath through spring-core.

The usual setup has two parts. The @NullMarked annotation on a package makes every type in it non-null by default, and @Nullable marks the few places where null is allowed.

@NullMarked
package com.howtodoinjava.boot4;

// in PantryService
public @Nullable PantryItem find(String name) {
  return items.get(name);                         // null for "kiwi"
}

IntelliJ IDEA 2025.3 and later warns when we use a @Nullable value without a check. To fail the build instead, we add NullAway through Error Prone, a static analysis plugin for javac. The project has it in a Maven profile, so mvn -Pnullaway compile runs the check.

Say a teammate adds a report class that reads the quantity without checking the result of find().

PantryItem item = pantryService.find(name);       // @Nullable return type
int quantity = item.quantity();                   // compile error with NullAway
[ERROR] /.../PantryReport.java:[12,24] [NullAway] dereferenced expression 'item' is @Nullable
    (see http://t.uber.com/nullaway )
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile)

The fix is the null check that PantryController already has. The check throws PantryNotFoundException, which Spring MVC turns into a 404 response through @ResponseStatus.

PantryItem item = pantryService.find(name);
if (item == null) {
  throw new PantryNotFoundException(name);        // 404 "No pantry item: kiwi"
}
return item;                                      // NullAway knows item is not null here

4. API Versioning in Spring Boot 4

API versioning lets one app serve old and new versions of the same endpoint, so existing clients keep working while new clients get the changed response. Before Spring Framework 7, we built it by hand with paths such as /v1/… or with custom header conditions. Spring Framework 7 adds first-class API versioning, and Spring Boot 4 configures it with the *spring.mvc.apiversion.** properties.

A typical case is a mobile app. Version 1 of the app reads only the item name and quantity, and version 2 also shows the best-before date. Old phones keep sending no version header, so they still get the version 1 response.

# read the version from this header
spring.mvc.apiversion.use.header=API-Version
# version for requests without the header
spring.mvc.apiversion.default=1

The controller maps the same path twice and sets the version attribute on each mapping.

@GetMapping(path = "/{name}", version = "1")
public PantryItemV1 getV1(@PathVariable String name) {
  return PantryItemV1.from(findOrThrow(name));    // name and quantity only
}

@GetMapping(path = "/{name}", version = "2")
public PantryItem getV2(@PathVariable String name) {
  return findOrThrow(name);                       // adds bestBefore
}

Let us call the endpoint without the header and with two version values.

$ curl localhost:8080/pantry/apple
{"name":"apple","quantity":5}

$ curl -H "API-Version: 2" localhost:8080/pantry/apple
{"name":"apple","quantity":5,"bestBefore":"2026-10-12"}

$ curl -i -H "API-Version: 3" localhost:8080/pantry/apple
HTTP/1.1 400
{"timestamp":"2026-10-05T03:06:22.636Z","status":400,"error":"Bad Request","path":"/pantry/apple"}

The 400 response comes from InvalidApiVersionException with the message “Invalid API version: ‘3.0.0’.”. The default SemanticApiVersionParser reads “3” as 3.0.0, and only the versions declared in the mappings are supported. Mappings without a version attribute, such as /pantry/{name}/price, match every version.

Instead of the header, Spring Boot can read the version from other parts of the request, each with its own property under spring.mvc.apiversion.use.

  • The query-parameter property takes the name of a query parameter, such as version for ?version=2.
  • The media-type-parameter property maps a media type to a parameter name, such as version in an Accept header of application/json;version=2.
  • The path-segment property takes the index of the path segment, such as 0 for /v2/pantry/apple.

5. HTTP Service Clients with @ImportHttpServices

An HTTP service client is a Java interface whose methods are annotated with @HttpExchange or @GetExchange, and Spring creates the implementation that sends the HTTP requests. The interfaces exist since Spring Framework 6, but declaring an @HttpExchange client needed an HttpServiceProxyFactory bean for every interface. Spring Framework 7 adds @ImportHttpServices, which registers the clients as beans, and Spring Boot 4 configures them per group with the *spring.http.serviceclient.<group>.** properties.

@HttpExchange("/prices")
public interface PriceClient {

  @GetExchange("/{item}")
  Price price(@PathVariable String item);         // GET {base-url}/prices/apple
}

The interface has no URL host. The prices group gets the base URL and the timeouts from application.properties, so each environment can point to its own supplier.

@SpringBootApplication
@EnableResilientMethods
@ImportHttpServices(group = "prices", types = PriceClient.class)
public class Boot4Application {

  public static void main(String[] args) {
    SpringApplication.run(Boot4Application.class, args);
  }
}
spring.http.serviceclient.prices.base-url=http://localhost:8080/supplier
spring.http.serviceclient.prices.connect-timeout=2s
spring.http.serviceclient.prices.read-timeout=3s

By default, the clients use RestClient to send the requests, which is why the app needs spring-boot-starter-restclient. We inject PriceClient like any other bean.

$ curl localhost:8080/pantry/apple/price
{"item":"apple","cents":120}

5.1. Retrying Failed Calls with @Retryable

A supplier API sometimes answers with a 503 for a second during a deployment. Spring Framework 7 moves the retry support of the former Spring Retry project into spring-core and spring-context, so @Retryable works without an extra dependency once @EnableResilientMethods is on a configuration class.

@Retryable(includes = HttpServerErrorException.class, maxRetries = 2, delay = 100)
public Price priceOf(String item) {
  log.info("Asking supplier for the price of {}", item);
  return priceClient.price(item);                 // 503 throws HttpServerErrorException
}

The PriceClientTest starts a stub supplier that answers the first “banana” request with 503 and the second with 200. We can see two calls for one priceOf(“banana”) invocation.

INFO ... com.howtodoinjava.boot4.PriceService : Asking supplier for the price of banana
INFO ... com.howtodoinjava.boot4.PriceService : Asking supplier for the price of banana
Supplier calls for banana: 2, result: Price[item=banana, cents=45]

We retry only server errors, because a 404 or a 400 fails the same way on every retry. Spring Boot 4 no longer manages the Spring Retry version, so apps that keep spring-retry must set the version themselves.

6. Jackson 3 Is the Default JSON Library

Spring Boot 4 uses Jackson 3 to read and write JSON in controllers and in HTTP clients such as RestClient. Jackson 3 changes the Maven group IDs and the Java packages from com.fasterxml.jackson to tools.jackson, so every Jackson import in our code needs an update. The annotations such as @JsonProperty and @JsonIgnore are the exception; they stay in com.fasterxml.jackson.annotation.

Spring Boot 3.5 with Jackson 2Spring Boot 4 with Jackson 3
com.fasterxml.jackson.databind.ObjectMappertools.jackson.databind.json.JsonMapper (auto-configured bean)
JsonProcessingException (checked)JacksonException (extends RuntimeException)
JsonParseException, JsonMappingExceptionStreamReadException, DatabindException
jackson-datatype-jsr310 for java.timeBuilt into jackson-databind
Jackson2ObjectMapperBuilderCustomizerJsonMapperBuilderCustomizer
@JsonComponent, @JsonMixin@JacksonComponent, @JacksonMixin
*spring.jackson.read., *spring.jackson.write.*spring.jackson.json.read., *spring.jackson.json.write.

Spring Boot auto-configures a JsonMapper bean, and to replace it, we define our own JsonMapper bean, because an ObjectMapper bean is no longer enough. The java.time types work without an extra module, and a LocalDate is written as an ISO date.

PantryItem apple = new PantryItem("apple", 5, LocalDate.of(2026, 10, 12));
String json = jsonMapper.writeValueAsString(apple);   // {"name":"apple","quantity":5,"bestBefore":"2026-10-12"}

Since JacksonException is unchecked, readValue() no longer forces a try-catch block. We still catch it where the JSON comes from outside, because a broken request body throws at runtime.

String json = "{\"name\":";
PantryItem item;
try {
  item = jsonMapper.readValue(json, PantryItem.class);
} catch (JacksonException e) {                        // UnexpectedEndOfInputException
  item = null;                                        // "Unexpected end-of-input within/between Object entries"
}

If an upgrade cannot change all Jackson code at once, spring.jackson.use-jackson2-defaults=true keeps the Jackson 2 defaults of Spring Boot 3.5. The deprecated spring-boot-jackson2 module brings back a Jackson 2 ObjectMapper with *spring.jackson2.** properties, and it will be removed in a future release. For more on mapping JSON in Spring MVC, see consuming and producing JSON with Jackson and Jackson date handling.

7. Testing with MockMvcTester and RestTestClient

Spring Boot 4 offers two test clients for web code. MockMvcTester wraps MockMvc with AssertJ assertions and needs no server, while RestTestClient is new in Spring Framework 7 and sends requests either through MockMvc or over HTTP to a running server. Both replace older choices; MockMvcTester replaces the Hamcrest-style MockMvc chains and RestTestClient replaces TestRestTemplate.

A @WebMvcTest slice starts only the web layer, so it runs fast. The annotation moved to the package org.springframework.boot.webmvc.test.autoconfigure, and mocks use @MockitoBean because @MockBean is removed.

@WebMvcTest(PantryController.class)
@Import(PantryService.class)
class PantryControllerTest {

  @Autowired MockMvcTester mvc;
  @MockitoBean PriceService priceService;

  @Test
  void versionTwoAddsBestBeforeAsIsoDate() {
    assertThat(mvc.get().uri("/pantry/apple").header("API-Version", "2"))
        .hasStatusOk()
        .bodyJson().extractingPath("$.bestBefore").isEqualTo("2026-10-12");
  }
}

For a test over real HTTP, we start the app on a random port and add @AutoConfigureRestTestClient. A plain @SpringBootTest no longer provides MockMvc or TestRestTemplate beans unless we add the matching @AutoConfigure… annotation.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class PantryApiIntegrationTest {

  @Autowired RestTestClient client;

  @Test
  void versionTwoOverRealHttp() {
    client.get().uri("/pantry/banana")
        .header("API-Version", "2")
        .exchange()
        .expectStatus().isOk()
        .expectBody(PantryItem.class)
        .value(item -> assertThat(item.quantity()).isEqualTo(3));
  }
}

8. Virtual Threads in Spring Boot 4

Virtual threads are lightweight threads managed by the JVM, so a request that waits for a database or a supplier API does not block an operating system thread. Spring Boot has supported them since 3.2 with one property, and Spring Boot 4 extends the property to the auto-configured HTTP clients that use the JDK HttpClient.

spring.threads.virtual.enabled=true

The /pantry/thread endpoint returns Thread.currentThread().toString(), and the output shows that Tomcat runs the request on a virtual thread.

$ curl localhost:8080/pantry/thread
VirtualThread[#59,tomcat-handler-8]/runnable@ForkJoinPool-1-worker-1

Virtual threads help apps that spend most of the request time waiting on I/O. They do not make CPU-heavy code faster, and the Spring Boot virtual threads article shows a load test with both cases.

9. Observability with the OpenTelemetry Starter

OpenTelemetry is the open standard for telemetry data such as traces and metrics, and OTLP is its protocol for sending them to a backend such as Jaeger. Spring Boot 4 adds spring-boot-starter-opentelemetry, which brings the OpenTelemetry SDK with its OTLP exporters and the Micrometer Tracing bridge, which sends the Spring observations to OpenTelemetry, in one dependency. In Spring Boot 3.5, we added the bridge and the exporter ourselves, next to the actuator starter.

# trace every request (default 0.1)
management.tracing.sampling.probability=1.0
management.opentelemetry.tracing.export.otlp.endpoint=http://localhost:4318/v1/traces
# no metrics backend in this demo
management.otlp.metrics.export.enabled=false

To see the traces, we start Jaeger, which accepts OTLP on port 4318. Every log line inside a request carries the trace ID and the span ID, where a span is one timed operation inside the trace.

docker run -d --name jaeger -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:2.21.0
INFO 12108 --- [pantry] [omcat-handler-3] [7c2d22739b75e4ce71c918709c7f37e6-0587c8e023b54e3b] com.howtodoinjava.boot4.PriceService : Asking supplier for the price of apple

One call to /pantry/apple/price creates three spans in the same trace. The RestClient behind PriceClient adds a traceparent header to the supplier request, so the supplier span joins the trace of the incoming request.

Sequence diagram of curl calling PantryController, which calls PriceService and the PriceClient proxy, which calls the supplier API with a traceparent header; the app exports the three spans of trace 7c2d22739b75e4ce71c918709c7f37e6 to Jaeger over OTLP on port 4318
The traceparent header carries the trace ID to the supplier, so Jaeger shows all three spans as one trace.

We can read the same trace from the Jaeger API.

$ curl -s "http://localhost:16686/api/v3/traces/7c2d22739b75e4ce71c918709c7f37e6" \
    | jq -r '.result.resourceSpans[].scopeSpans[].spans[] | "\(.spanId)  parent=\(.parentSpanId // "-")  \(.name)"'
0587c8e023b54e3b  parent=-  http get /pantry/{name}/price
1c060708efc4a3b7  parent=693ba642429099bf  http get /supplier/prices/{item}
693ba642429099bf  parent=0587c8e023b54e3b  http get

The Spring Boot observability reference lists the OTLP properties for metrics and logs, and the Spring Boot microservices tutorial follows one trace ID across several services.

10. What’s New in Spring Boot 4.1

Spring Boot 4.1.0 was released on June 10, 2026 on top of Spring Framework 7.0. Spring Boot 4.1 removes the APIs deprecated in 4.0, so an app on 4.0 must be free of deprecation warnings before the upgrade.

FeatureWhat it doesHow to use it
Spring gRPCgRPC servers (Netty or Servlet over HTTP/2) and clientsspring-boot-starter-grpc-server, spring-boot-starter-grpc-client
SSRF protectionBlocks outgoing HTTP calls to chosen addressesInetAddressFilter bean or HttpClientSettings
Jackson factory constraintsLimits for document length, nesting depth and string length*spring.jackson.factory.constraints.read.**
Jackson read and write featuresFormat-independent features for JSON, CBOR and XML*spring.jackson.read., *spring.jackson.write.
Log4j file rotationRolls Log4j log files by size, time, size-and-time or cronlogging.log4j2.rollingpolicy.strategy
OpenTelemetryTurn off the SDK, choose a sampler, read *OTEL_** environment variablesmanagement.opentelemetry.enabled, management.opentelemetry.tracing.sampler
Async context propagationTrace context reaches @Async methodsspring.task.execution.propagate-context=true
Lazy JDBC connectionsTakes a pooled connection only when a statement runsspring.datasource.connection-fetch=lazy
@RedisListenerAnnotation-based Redis Pub/Sub listeners*spring.data.redis.listener.**

Spring Boot 4.1 also deprecates the Apache Derby support and the DevTools LiveReload feature. The layertools jar mode is removed in favor of tools. The format-independent *spring.jackson.read. and *spring.jackson.write. properties reuse the names that 4.0 moved under spring.jackson.json (see section 6), and they apply to JSON, CBOR and XML alike. The Spock integration, removed in 4.0, is back. For Log4j2 in Spring Boot, the new rotation properties replace a custom RollingFile policy in many apps.

10.1. Blocking SSRF with InetAddressFilter

Server-side request forgery (SSRF) happens when an app fetches a URL that a user controls, and the user points it at an internal address such as a cloud metadata service or an admin port. Spring Boot 4.1 adds InetAddressFilter, which checks the target address of every outgoing request of a client and rejects the ones that do not match.

HttpClientSettings settings = HttpClientSettings.defaults()
    .withInetAddressFilter(InetAddressFilter.externalAddresses());   // public addresses only
RestClient restClient = RestClient.builder()
    .requestFactory(ClientHttpRequestFactoryBuilder.jdk().build(settings))
    .build();

Price price = restClient.get()
    .uri("http://127.0.0.1:8080/supplier/prices/apple")
    .retrieve()
    .body(Price.class);              // FilteredHostException: Filtered host '127.0.0.1'

The request never leaves the app, and the call throws FilteredHostException. To protect every auto-configured HTTP client, including the HTTP service clients, we declare the filter as a @Bean instead, for example InetAddressFilter.of(“192.168.1.0/24”).andNot(“192.168.1.1”).

10.2. Limiting JSON Nesting with Jackson Properties

Deeply nested JSON is a common way to exhaust the stack or the CPU of a parser. With Spring Boot 4.1, the read constraints of Jackson’s StreamReadConstraints are properties, so we can lower them without a customizer bean.

# default 500
spring.jackson.factory.constraints.read.max-nesting-depth=2
tools.jackson.core.exc.StreamConstraintsException: Document nesting depth (3) exceeds the maximum allowed (2, from `StreamReadConstraints.getMaxNestingDepth()`)

A real app keeps a much higher value, and the test uses 2 only to show the exception with the short JSON {“a”:{“b”:{“c”:1}}}.

11. Removals and Deprecations in Spring Boot 4

Spring Boot 4.0 removes every class, method and property that Spring Boot 3.x deprecated, and drops a few integrations whose libraries do not support the new baseline. The Spring Boot 4.0 release notes list the rest.

  • Undertow support is removed, because Undertow does not support Servlet 6.1. We switch to Tomcat or Jetty.
  • Embedded launch scripts for “fully executable” jars are removed. We run the jar with java -jar.
  • Spring Boot’s @MockBean and @SpyBean are removed in favor of @MockitoBean and @MockitoSpyBean from Spring Framework.
  • The classic uber-jar loader (loaderImplementation CLASSIC) is removed.
  • Spring Session Hazelcast and Spring Session MongoDB, as well as the reactive Pulsar client, are no longer part of Spring Boot.
  • Spring Framework 7 no longer supports the javax.annotation and javax.inject annotations, such as @javax.annotation.PostConstruct. We use the *jakarta.** equivalents.
  • Spring Framework 7 deprecates RestTemplate in its reference documentation and plans @Deprecated for 7.1, so new code uses RestClient.
  • Jackson 2 support, JUnit 4 support in the TestContext Framework and Spring Boot’s HttpMessageConverters are deprecated.

12. Upgrading from Spring Boot 3.5 to Spring Boot 4

Most apps hit the same breaking changes, and each one has a known fix. The order of the steps matters, because each step makes the next one smaller.

Flowchart of the upgrade from Spring Boot 3.5 to 4: latest 3.5.x without deprecation warnings, parent 4.1.1 with the properties migrator, classic starters to compile, code fixes, technology starters, and a check of tests and startup that loops back to code fixes or ends with removing the properties migrator
The classic starters give a compiling app early, and the technology starters replace them once the code is fixed.

The spring-boot-properties-migrator dependency (runtime scope) prints every renamed property at startup and maps it for us during the upgrade, so we add it first and remove it at the end.

Breaking changeWhat we seeFix
Starters split and renamedBuild error for spring-boot-starter-aop; Flyway migrations stop runningUse the names from section 2.1, add spring-boot-starter-flyway or -liquibase
Jackson 3 packagespackage com.fasterxml.jackson.databind does not existChange imports to tools.jackson, define a JsonMapper bean instead of an ObjectMapper bean, or set spring.jackson.use-jackson2-defaults=true for the old defaults
@MockBean removedcannot find symbol MockBeanUse @MockitoBean and @MockitoSpyBean (more on mocking beans)
No MockMvc in @SpringBootTestNoSuchBeanDefinitionException for MockMvcAdd @AutoConfigureMockMvc, or @AutoConfigureTestRestTemplate for TestRestTemplate
Test packages movedcannot find symbol WebMvcTestImport org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest, use the matching -test starter
Renamed propertiesA setting such as management.tracing.enabled is ignoredRead the migrator output, for example use management.tracing.export.enabled, *spring.session.data.redis.**, spring.mongodb.host
Moved Spring Boot classescannot find symbol EntityScanImport org.springframework.boot.persistence.autoconfigure.EntityScan; EnvironmentPostProcessor moved to org.springframework.boot
Spring Retry not managedBuild error, the version of spring-retry is missingAdd an explicit version or move to the @Retryable of section 5.1
Undertow removedBuild error, the version of spring-boot-starter-undertow is missingUse the default Tomcat or spring-boot-starter-jetty
Health probes on by default/actuator/health shows the liveness and readiness groupsSet management.endpoint.health.probes.enabled=false if they are not needed
Spring Cloud versionStartup fails with a compatibility errorUse Spring Cloud 2025.1.x, e.g. for Spring Cloud Gateway; 2025.1.3 supports Spring Boot 4.0 and 4.1

For a large codebase, the OpenRewrite recipe for Spring Boot 4.0 automates many of the import and starter changes. We still run the full test suite afterward, because the recipe does not change behavior such as the Jackson defaults. The Spring Boot 4.0 migration guide lists the remaining cases.

13. Conclusion

Spring Boot 4 keeps the Java 17 baseline but moves everything else to a new generation, namely Spring Framework 7, Jakarta EE 11 with Servlet 6.1, Jackson 3 and JUnit 6. In a typical app, we see the change in the focused starters and the tools.jackson imports. Tests also need an explicit @AutoConfigure… annotation for MockMvc in a @SpringBootTest.

In return, we get features that used to need extra libraries or hand-written code. JSpecify annotations with NullAway catch null mistakes at compile time, and @ImportHttpServices turns an interface into a configured HTTP client. API versioning and @Retryable are part of the framework.

Spring Boot 4.1 adds gRPC, SSRF protection with InetAddressFilter, Jackson read limits as properties and more OpenTelemetry options on top.

For the rest of the Spring Boot topics, see the Spring Boot tutorial index, and for the previous generation, what’s new in Spring 6 and Spring Boot 3.

14. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.