Skip to content
Back to skills

Spring Boot 4

ASecurity

Build, review and upgrade Spring Boot 4 web services (checked against Boot 4.1.2, Spring Framework 7.0.10, Jackson 3.1.7). Covers the split starters and test starters, Jackson 3 (tools.jackson packages, @JacksonComponent, ValueSerializer, JsonMapperBuilderCustomizer, the ObjectMapper bean Boot 4 ignores, default changes that alter JSON silently), built-in method validation versus class-level @Validated, HandlerMethodValidationException and RFC 9457 problem details with per-field errors, Boot ...

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
ai-agentsjavascriptgojavanodeexpressspringtestingapidocumentation

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 8 files and shows the line behind each finding

Scanned November 7, 2026

npx -y skills add dgesteves/sigil --skill spring-boot-4 --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Spring Boot 4?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Spring Boot 4
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/dgesteves-spring-boot-4/badge)](https://www.skillsdirectory.com/skills/dgesteves-spring-boot-4)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: spring-boot-4
description: Build, review and upgrade Spring Boot 4 web services (checked against Boot 4.1.2, Spring Framework 7.0.10, Jackson 3.1.7). Covers the split starters and test starters, Jackson 3 (tools.jackson packages, @JacksonComponent, ValueSerializer, JsonMapperBuilderCustomizer, the ObjectMapper bean Boot 4 ignores, default changes that alter JSON silently), built-in method validation versus class-level @Validated, HandlerMethodValidationException and RFC 9457 problem details with per-field errors, Boot 4 test packages (@MockitoBean, RestTestClient, TestRestTemplate opt-in), RestTemplateBuilder and HTTP client moves, API versioning, core @Retryable and virtual threads. Use when a build uses spring-boot-starter-parent 4.x, when upgrading from Boot 3, when a build fails on com.fasterxml.jackson.databind, @MockBean, @WebMvcTest or TestRestTemplate, or when customizing JSON, validating requests, shaping error responses or writing Spring MVC tests.
license: MIT
---

# Spring Boot 4 web services

Spring Boot 4 is the current major (`4.1.2` as of November 2026, on Spring Framework `7.0.10`,
Jackson `3.1.7`, Hibernate Validator `9.1.5`, Tomcat `11.0.27`, JUnit `6.0.3`; Java 17+, 25 is the
current LTS). Most Spring code an agent has seen is Boot 2 or 3. Much of it no longer compiles on 4,
and the dangerous part is what still compiles and does something else. Every behaviour below was
measured on 4.1.2 unless it cites a document.

## 1. Know which Boot you are on

```sh
mvn help:evaluate -Dexpression=project.parent.version -q -DforceStdout   # Maven, parent POM
./gradlew dependencyInsight --dependency spring-boot                     # Gradle
```

| Boot | Framework | JSON | Notes |
| --- | --- | --- | --- |
| 3.5.x | 6.2 | Jackson 2 (`com.fasterxml.jackson`) | last 3.x line; `@MockitoBean` already exists, `@MockBean` deprecated |
| 4.0.x, 4.1.x | 7.0 | Jackson 3 (`tools.jackson`) | modular starters, Jackson 2 only as a deprecated module |

Builds in this kind of repo are often hermetic (`.mvn/maven.config` pointing at a repo-local
repository, `mvn -o`). Before adding a dependency, check it is already there
(`ls .m2/repository/org/springframework/boot/`); you cannot download in an offline build.

## 2. Starters are per technology now

Boot 4 split the auto-configuration into modules (`spring-boot-<tech>`, packages
`org.springframework.boot.<tech>`) with one starter and one test starter each.

| You need | Boot 4 dependency | Boot 3 habit that breaks |
| --- | --- | --- |
| Spring MVC + Tomcat + Jackson | `spring-boot-starter-webmvc` | `spring-boot-starter-web` still resolves but is deprecated |
| `@WebMvcTest`, `@AutoConfigureMockMvc` | `spring-boot-starter-webmvc-test` (test) | `spring-boot-starter-test` alone: those annotations don't exist |
| `RestTemplateBuilder`, `RestClient.Builder` bean, HTTP service clients | `spring-boot-starter-restclient` | came with the web starter in 3.x |
| `TestRestTemplate` | `spring-boot-resttestclient` (inside `-webmvc-test`) **and** `@AutoConfigureTestRestTemplate` | injected by `@SpringBootTest(RANDOM_PORT)` in 3.x |
| Bean Validation | `spring-boot-starter-validation` | unchanged |

`spring-boot-starter-test` still brings JUnit, AssertJ, Mockito, JSONPath and `spring-test`; each
technology test starter brings it transitively. The `*-classic` starters exist to get a big upgrade
compiling, not as a destination. Full map: [references/upgrade-from-3.md](references/upgrade-from-3.md).

## 3. Jackson 3: what changed under your JSON

### 3.1 Packages and types

Jackson 3 lives in `tools.jackson.*` (group `tools.jackson.core`). The annotations did **not** move:
`com.fasterxml.jackson.annotation.*` (`@JsonProperty`, `@JsonInclude`, `@JsonValue`, `@JsonCreator`)
is still right. Everything else did.

| Jackson 2 / Boot 3 | Jackson 3 / Boot 4 |
| --- | --- |
| `com.fasterxml.jackson.databind.ObjectMapper` | `tools.jackson.databind.json.JsonMapper` (immutable; `JsonMapper.builder()`, `mapper.rebuild()`) |
| `JsonSerializer<T>`, `JsonDeserializer<T>` | `ValueSerializer<T>`, `ValueDeserializer<T>` |
| `SerializerProvider` | `SerializationContext` |
| `JsonProcessingException` (checked) | `JacksonException` (unchecked); no `throws IOException` |
| `gen.writeStringField(name, v)` | `gen.writeStringProperty(name, v)` |
| `p.getCodec().readTree(p)`, `node.asText()`, `isTextual()` | `ctxt.readTree(p)`, `node.asString()`, `isString()` |
| `Module` beans | `JacksonModule` beans (still auto-registered) |
| `@JsonComponent`, `@JsonMixin` | `@JacksonComponent`, `@JacksonMixin` (`org.springframework.boot.jackson`) |
| `Jackson2ObjectMapperBuilderCustomizer` | `JsonMapperBuilderCustomizer` (`org.springframework.boot.jackson.autoconfigure`) |
| `SerializationFeature.WRITE_DATES_AS_TIMESTAMPS` | `DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS` (`tools.jackson.databind.cfg`) |
| `SerializationFeature.WRITE_ENUMS_USING_TO_STRING` | `EnumFeature.WRITE_ENUMS_USING_TO_STRING` |
| `builder.serializationInclusion(NON_NULL)` | `builder.changeDefaultPropertyInclusion(v -> v.withValueInclusion(NON_NULL))` |

`@JsonSerialize`/`@JsonDeserialize`/`@JsonNaming` moved to `tools.jackson.databind.annotation`. If a
leftover `com.fasterxml.jackson.databind.annotation` import still compiles (Jackson 2 on the
classpath through some other library), Jackson 3 ignores it silently.

### 3.2 Configure the mapper Boot uses, not a second one

1. **Properties first**: `spring.jackson.property-naming-strategy=SNAKE_CASE`,
   `spring.jackson.default-property-inclusion=non_null`,
   `spring.jackson.deserialization.fail-on-unknown-properties=true`,
   `spring.jackson.datatype.enum.write-enums-using-to-string=false`,
   `spring.jackson.datatype.datetime.*`. JSON parser and generator features
   (`JsonReadFeature`/`JsonWriteFeature`) are `spring.jackson.json.read.*`/`json.write.*`;
   `spring.jackson.read.*`/`write.*` now take the format-neutral `StreamReadFeature`/`StreamWriteFeature`.
2. **Code**: a `JsonMapperBuilderCustomizer` bean.
3. **Replace entirely**: a `JsonMapper` bean (declared type `JsonMapper`).

**Never `@Bean ObjectMapper` in Boot 4.** Measured on 4.1.2: a bean declared as `ObjectMapper`
(even built with `JsonMapper.builder()`) leaves Boot's own `jacksonJsonMapper` in place, Spring MVC
keeps using Boot's, and your naming strategy, inclusion and modules silently do nothing. Add
`@Primary` and the context fails to start ("more than one 'primary' bean found among candidates:
[objectMapper, jacksonJsonMapper]").

`@WebMvcTest` slices pick up `@JacksonComponent` beans but not a `JsonMapperBuilderCustomizer` declared
in a `@Configuration` class (measured). Prefer properties for wire-format rules, or test the JSON
contract with `@SpringBootTest`.

### 3.3 Serializers for types you don't own

```java
@JacksonComponent
public class FareJsonComponent {
  public static class Serializer extends ValueSerializer<Fare> {
    @Override
    public void serialize(Fare fare, JsonGenerator gen, SerializationContext ctxt) {
      gen.writeStartObject();
      gen.writeStringProperty("value", fare.value().toPlainString());
      gen.writeStringProperty("currency", fare.currency().getCurrencyCode());
      gen.writeEndObject();
    }
  }

  public static class Deserializer extends ValueDeserializer<Fare> {
    @Override
    public Fare deserialize(JsonParser p, DeserializationContext ctxt) {
      JsonNode node = ctxt.readTree(p);
      JsonNode value = node.get("value");
      JsonNode currency = node.get("currency");
      if (value == null || !value.isString() || currency == null || !currency.isString()) {
        return ctxt.reportInputMismatch(Fare.class, "expected {\"value\": \"..\", \"currency\": \"..\"}");
      }
      try {
        return Fare.of(value.asString(), currency.asString());
      } catch (IllegalArgumentException e) {
        return ctxt.reportInputMismatch(Fare.class, "invalid fare: %s", e.getMessage());
      }
    }
  }
}
```

Imports: `tools.jackson.core.{JsonGenerator,JsonParser}`, `tools.jackson.databind.*`. An exception
thrown inside a deserializer becomes `HttpMessageNotReadableException` and a 400 (measured, even a
bare `IllegalArgumentException`), but reporting a mismatch gives a clear message. Money-like values
go out as strings so JavaScript clients don't lose cents. When you can annotate the type,
`@JsonValue`/`@JsonCreator` (or a `@JacksonMixin` for types you can't edit) is often enough.

### 3.4 Jackson 3 defaults that change the wire format silently

Measured on Boot 4.1.2, default settings versus the same app with
`spring.jackson.use-jackson2-defaults=true`; the enum and primitive rows were also confirmed on a
real Boot 3.5.16 build:

| Input or output | Jackson 2 behaviour (Boot 3.5) | Boot 4 / Jackson 3 defaults |
| --- | --- | --- |
| enum with an overridden `toString()`, written | `"DELAYED"` (name) | `"Running late"` (toString) |
| same enum, read | name accepted, label rejected | **name rejected (400)**, label accepted |
| record/POJO with `boolean`/`int` property missing from the body | `false`/`0` | **400** (`FAIL_ON_NULL_FOR_PRIMITIVES`) |
| explicit `null` for a primitive | `0` | **400** |
| trailing content after the JSON value | ignored | **400** (`FAIL_ON_TRAILING_TOKENS`) |
| property order of a getter-based class | declaration order | alphabetical (records keep component order) |
| `java.util.Date` | `"1970-01-01T00:00:00.000+00:00"` | `"1970-01-01T00:00:00.000Z"` |
| unknown properties | accepted (Boot default) | accepted (Boot default) |
| `Instant`, `LocalDate`, `Duration` | ISO strings (Boot disabled timestamps) | ISO strings, nanoseconds kept |

On an upgrade, either restore the old behaviour per feature (preferred, explicit):

```java
@Bean
JsonMapperBuilderCustomizer keepJackson2WireFormat() {
  return builder -> builder
      .disable(EnumFeature.WRITE_ENUMS_USING_TO_STRING)
      .disable(EnumFeature.READ_ENUMS_USING_TO_STRING)
      .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES);
}
```

or the same as properties (`spring.jackson.datatype.enum.write-enums-using-to-string=false`,
`spring.jackson.datatype.enum.read-enums-using-to-string=false`,
`spring.jackson.deserialization.fail-on-null-for-primitives=false`, all measured), or set
`spring.jackson.use-jackson2-defaults=true` and remove it once clients are checked. Do not
"fix" a toString-based enum by deleting the `toString()` override: other code uses it. Details and
the full rename table: [references/jackson-3.md](references/jackson-3.md).

## 4. Validation and error responses

### 4.1 Let Spring MVC validate; no class-level `@Validated` on controllers

Put constraints straight on handler parameters. Spring MVC (since Framework 6.1) validates them itself:

```java
@RestController
class JourneyController {
  @GetMapping("/journeys")
  JourneyPage search(@RequestParam(defaultValue = "0") @Min(0) int page,
                     @RequestParam(defaultValue = "25") @Min(1) @Max(200) int limit) { ... }

  @PostMapping("/stations/{stationCode}/departures")
  Departure add(@PathVariable @Pattern(regexp = "[A-Z]{3}") String stationCode,
                @Valid @RequestBody NewDeparture body) { ... }
}
```

| Situation (measured) | Exception | Default response |
| --- | --- | --- |
| constraint on `@RequestParam`/`@PathVariable`, no class `@Validated` | `HandlerMethodValidationException` | 400 |
| same method also has `@Valid @RequestBody`, both invalid | **one** `HandlerMethodValidationException` with parameter and body errors | 400 |
| `@Valid @RequestBody` on a method with no other constrained parameter | `MethodArgumentNotValidException` | 400 |
| body is a `List<@Valid Leg>` or `@Valid List<Leg>` | `HandlerMethodValidationException` | 400 |
| **class-level `@Validated`** + constrained parameter | `ConstraintViolationException` from the AOP proxy | **500** |
| class-level `@Validated`, invalid parameter and invalid body | `MethodArgumentNotValidException` only: **parameter errors lost** | 400 |
| non-numeric `limit=abc` | `MethodArgumentTypeMismatchException` | 400 |
| malformed JSON, missing primitive, wrong enum | `HttpMessageNotReadableException` | 400 |

The class-level `@Validated` answer is everywhere in older material. With it, Spring MVC skips its
own method validation, the AOP interceptor throws `ConstraintViolationException` (a 500 unless you
map it), and the body is validated first and alone. Keep `@Validated` for `@Service` beans and
`@ConfigurationProperties`, not controllers.

### 4.2 Problem details (RFC 9457)

- `spring.mvc.problemdetails.enabled=true` (default `false`) registers Boot's
  `ProblemDetailsExceptionHandler`: every Spring MVC exception becomes `application/problem+json`
  with `type`, `title`, `status`, `detail`, `instance`. The default details are terse
  (`"Validation failure"`, `"Invalid request content."`) and list no fields.
- To add fields, **extend `ResponseEntityExceptionHandler`** in a `@RestControllerAdvice` and
  override `handleMethodArgumentNotValid` and `handleHandlerMethodValidationException`. Boot's
  handler backs off when such a bean exists, and you keep problem details for all other exceptions.
- Trap (measured): with the property on, a plain `@RestControllerAdvice` with
  `@ExceptionHandler(MethodArgumentNotValidException.class)` never runs, because Boot's ordered
  handler answers first. Extend `ResponseEntityExceptionHandler`, or give your advice
  `@Order(Ordered.HIGHEST_PRECEDENCE)`.

```java
@RestControllerAdvice
class ApiProblems extends ResponseEntityExceptionHandler {
  @Override
  protected ResponseEntity<Object> handleHandlerMethodValidationException(
      HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
    List<Map<String, String>> errors = new ArrayList<>();
    for (ParameterValidationResult result : ex.getParameterValidationResults()) {
      if (result instanceof ParameterErrors body) {          // a @Valid @RequestBody / @ModelAttribute
        body.getFieldErrors().forEach(e -> errors.add(Map.of("field", e.getField(), "message", e.getDefaultMessage())));
      } else {                                                // @RequestParam, @PathVariable, @RequestHeader
        String name = result.getMethodParameter().getParameterName();
        result.getResolvableErrors().forEach(e -> errors.add(Map.of("field", name, "message", e.getDefaultMessage())));
      }
    }
    ProblemDetail problem = ex.getBody();
    problem.setProperty("errors", errors);
    return handleExceptionInternal(ex, problem, headers, status, request);
  }
  // handleMethodArgumentNotValid: same, from ex.getBindingResult().getFieldErrors()
}
```

`FieldError.getField()` is already a property path (`legs[1].fare`). `getParameterName()` needs
`-parameters`, which `spring-boot-starter-parent` turns on; with an explicit
`@RequestParam("max-results")` use the annotation's name (or `ex.visitResults(...)`). Full recipe,
ordering and message customization: [references/validation-and-problem-details.md](references/validation-and-problem-details.md).

## 5. Tests on Boot 4

| Need | Boot 4 |
| --- | --- |
| mock a bean | `@MockitoBean` / `@MockitoSpyBean` (`org.springframework.test.context.bean.override.mockito`); `@MockBean`/`@SpyBean` are removed |
| MVC slice | `@WebMvcTest` from `org.springframework.boot.webmvc.test.autoconfigure` |
| MockMvc in `@SpringBootTest` | add `@AutoConfigureMockMvc` (same package); no longer implicit |
| AssertJ-style MockMvc | `MockMvcTester` (`org.springframework.test.web.servlet.assertj`), autowirable where MockMvc is |
| real HTTP client in tests | `RestTestClient` (`org.springframework.test.web.servlet.client`) + `@AutoConfigureRestTestClient` |
| keep `TestRestTemplate` | `org.springframework.boot.resttestclient.TestRestTemplate` + `@AutoConfigureTestRestTemplate` |
| port | `@LocalServerPort` (`org.springframework.boot.test.web.server`, unchanged) |

Spring caches one context per configuration, so state written by one test class (an in-memory
store) is visible to the next. Don't assert global counts; assert on what the test created.
Mockito prints a self-attach warning on JDK 25; it is harmless today.
More in [references/testing.md](references/testing.md).

## 6. Clients, versioning, resilience, threads

- **`RestTemplateBuilder`** moved to `org.springframework.boot.restclient` (needs the restclient
  starter), and `setConnectTimeout`/`setReadTimeout` are gone: use `connectTimeout(Duration)` and
  `readTimeout(Duration)`. Prefer `RestClient` for new code.
- **HTTP service clients**: `@ImportHttpServices(group = "routing", types = RoutingApi.class)` on a
  `@Configuration`, interfaces with `@HttpExchange`/`@GetExchange`, settings per group under
  `spring.http.serviceclient.routing.*` (`base-url` measured; `connect-timeout`, `read-timeout`
  from the property metadata). Global client settings moved from `spring.http.client.*` to
  `spring.http.clients.*`.
- **API versioning** (`spring.mvc.apiversion.*`, `@GetMapping(path = "/x", version = "2.0")`):
  configuring `use.header` without `default` makes **every** request without the header a 400,
  including endpoints that are not versioned (measured). Set `spring.mvc.apiversion.default` or
  `required=false`. Versions parse semantically: `2` matches `2.0`.
- **Retries**: Framework 7 has `@Retryable` and `@ConcurrencyLimit`
  (`org.springframework.resilience.annotation`, enable with `@EnableResilientMethods`) and
  `RetryTemplate`/`RetryPolicy` in `org.springframework.core.retry`. Spring Retry has no managed
  version in Boot 4. `@Retryable(maxRetries = 3)` means up to 4 calls, then the last exception is
  rethrown (measured). It is proxy-based: self-invocation is not retried.
- **Virtual threads**: `spring.threads.virtual.enabled=true` runs requests on virtual threads
  (measured: `tomcat-handler-N`, `isVirtual()` true).

Details: [references/http-and-resilience.md](references/http-and-resilience.md).

## 7. Upgrading a 3.5 service to 4.x

1. Get to the latest 3.5.x first with no deprecation warnings; `@MockitoBean` works there already.
2. Bump the parent, then swap starters (section 2), adding each technology's test starter.
3. Fix compile errors package by package: Jackson (3.1), test annotations (section 5),
   `RestTemplateBuilder`, moved Boot classes. Use `javap`/`unzip -l` on jars in the local repository
   to find a moved class instead of guessing.
4. **Then diff behaviour, not just compilation.** Record a few real responses and requests on 3.5
   (enums, omitted booleans, dates, nulls, error bodies) and replay them on 4. Jackson 3 changes
   them without a single compile error (3.4).
5. Rename properties (`spring.http.client.*`, `spring.jackson.read/write.*`, others in the
   reference). Run with `spring-boot-properties-migrator` on the classpath once to list them.
6. Keep every existing test; port, don't delete.

Checklist with all renames: [references/upgrade-from-3.md](references/upgrade-from-3.md).

## Review checklist

- [ ] Starters match the technologies used, and each has its `-test` starter where tests need it.
- [ ] No `com.fasterxml.jackson.databind`/`core` imports; annotations stay `com.fasterxml.jackson.annotation`.
- [ ] JSON is configured through properties, `JsonMapperBuilderCustomizer` or a `JsonMapper` bean, never `@Bean ObjectMapper`.
- [ ] Enums with a custom `toString()`, primitive request fields and dates have been checked against the published contract.
- [ ] Controllers carry no class-level `@Validated`; constraints sit on parameters and `@Valid` bodies.
- [ ] Both `MethodArgumentNotValidException` and `HandlerMethodValidationException` produce the same error format, and a request with bad parameters and a bad body reports both.
- [ ] Error responses are `application/problem+json`, either via the property or a `ResponseEntityExceptionHandler` subclass, not both fighting.
- [ ] Tests use `@MockitoBean`, Boot 4 test packages, and opt in to MockMvc/TestRestTemplate/RestTestClient explicitly.
- [ ] API versioning, if enabled, has a default or is optional for unversioned endpoints.

## Anti-patterns

- `@Bean ObjectMapper objectMapper()` → ignored by MVC in Boot 4; with `@Primary`, startup failure.
- `@JsonComponent` + `extends JsonSerializer<T>` → doesn't compile on 4; use `@JacksonComponent` + `ValueSerializer<T>`.
- Adding `jackson-databind` 2.x or `spring-boot-jackson2` to make old code compile → two JSON stacks, deprecated support, and Spring MVC still uses Jackson 3.
- `@Validated` on a `@RestController` → 500s for invalid parameters, parameter errors dropped when the body is also invalid.
- Handling only `MethodArgumentNotValidException` → parameter violations come back without field details.
- A plain `@ExceptionHandler(MethodArgumentNotValidException.class)` plus `spring.mvc.problemdetails.enabled=true` → your handler never runs.
- `@MockBean` → removed. `@SpringBootTest` + `@Autowired MockMvc` without `@AutoConfigureMockMvc` → no bean.
- `spring-retry` with no version → unresolvable in Boot 4; use Framework 7 `@Retryable`.
- `spring.mvc.apiversion.use.header` without a default → every unversioned request is a 400.

## More

- [references/jackson-3.md](references/jackson-3.md): Jackson 2 to 3 renames, Boot properties, defaults, serializer recipes.
- [references/validation-and-problem-details.md](references/validation-and-problem-details.md): validation exceptions, the full error-format handler, messages.
- [references/testing.md](references/testing.md): Boot 4 test annotations, slices, MockMvcTester, RestTestClient.
- [references/upgrade-from-3.md](references/upgrade-from-3.md): starters, modules, packages and properties that moved.
- [references/http-and-resilience.md](references/http-and-resilience.md): RestClient, HTTP service groups, API versioning, retries, virtual threads.
- [references/sources.md](references/sources.md): documentation and measurements behind every claim.

Files in this skill

  • SKILL.md21.5 KB
  • references/http-and-resilience.md6.9 KB
  • references/jackson-3.md8.9 KB
  • references/sources.md9.3 KB
  • references/testing.md6.2 KB
  • references/upgrade-from-3.md6.6 KB
  • references/validation-and-problem-details.md9.1 KB
  • sigil.yaml2.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.