On This Page
The compiler error that kicked this off
I was moving a small internal service from Boot 3.5 to Boot 4.1, and the first ten minutes went exactly the way these things usually go: bump the parent version, swap spring-boot-starter-web for spring-boot-starter-webmvc, run the build, watch it catch fire in a small, predictable way.
[ERROR] /home/dev/repro/src/main/java/com/example/repro/MoneyJsonComponent.java:[8,40] cannot find symbol
[ERROR] symbol: class JsonComponent
[ERROR] location: package org.springframework.boot.jackson
[ERROR] /home/dev/repro/src/main/java/com/example/repro/MoneyJsonComponent.java:[10,2] cannot find symbol
[ERROR] symbol: class JsonComponent
[ERROR] -> [Help 1]Fine. Annotations move around between major versions, I've seen it a dozen times. I found the new package, swapped the import, and the build went green. That's the part I expected.
What I didn't expect was for the bug to come back a few hours later, quieter, with no stack trace anywhere, wearing a completely different shape. The service compiled, started, answered requests, and every one of them was subtly wrong. That second version of the bug is the one that actually cost me the day, and it's the one most people migrating to Boot 4 are going to hit whether they ever see the compiler error or not.
Versions for everything below: Spring Boot 4.1.1, Spring Framework 7.0.9, Java 21, Maven. This is a Boot 4.0+ problem specifically. Jackson 3 didn't exist as an option before that, so if you're still on 3.5 none of this applies to you yet.
Whose class actually vanished
Before chasing the fix, it's worth being precise about what actually happened, because "cannot find symbol" reads like something got deleted, and that's not quite it.
org.springframework.boot.jackson.JsonComponent, the annotation you've probably used since Boot 1.4 to hang a custom JsonSerializer off a domain type, didn't get deleted. It got renamed and relocated, twice, in the same release. Boot 4 introduces a brand new @JacksonComponent annotation in that same org.springframework.boot.jackson package, built for Jackson 3's serializer types. The old @JsonComponent, still built for Jackson 2's com.fasterxml.jackson.databind.JsonSerializer, moved out to its own module under a new package, org.springframework.boot.jackson2. Same annotation, same behavior, different coordinates, and it's no longer on your classpath unless you go get it.
That's the whole compiler error. It's not really about a missing feature. It's a package that used to hold one annotation now holding a different one with a similar name, and the thing you actually wanted moved next door.
If you hit this at the field level instead, say @Autowired ObjectMapper objectMapper somewhere that isn't a @JsonComponent at all, you'll get a different, louder failure: NoSuchBeanDefinitionException: No qualifying bean of type 'com.fasterxml.jackson.databind.ObjectMapper' available. Same root cause, different presentation. Boot 4's default Jackson auto-configuration only ever produces a tools.jackson.databind.ObjectMapper (Jackson 3). If your import is still com.fasterxml.jackson.databind.ObjectMapper, that bean genuinely does not exist in a default Boot 4 app, and no amount of component scanning fixes an import.
Watch it happen in about four minutes
You don't need a database or a broker for this one, just a JDK and Maven. Here's the pinned setup. start.spring.io was unreachable when I put this together, so this is the pom.xml written directly against Boot 4.1.1's dependency management rather than an Initializr command.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<groupId>com.example</groupId>
<artifactId>repro</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>A Money type and a controller that returns it:
package com.example.repro;
import java.math.BigDecimal;
public record Money(BigDecimal amount, String currency) {
}package com.example.repro;
import java.math.BigDecimal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BalanceController {
@GetMapping("/accounts/1/balance")
Money balance() {
return new Money(new BigDecimal("12.50"), "EUR");
}
}And the custom serializer, carried over unchanged from the Boot 3.5 codebase. This is the file that won't compile yet:
package com.example.repro;
import java.io.IOException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.boot.jackson.JsonComponent; // this import is the whole bug
@JsonComponent
public class MoneyJsonComponent {
public static class Serializer extends JsonSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeString(value.amount() + " " + value.currency());
}
}
}Step by step:
Step 1 — build it
$ mvn -q clean compile
Expected: fails in under two seconds with the "cannot find symbol: class
JsonComponent" error from the top of this article. That's the confirmation
you've reproduced it.
Step 2 — add the compatibility module (Maven, no version needed, it's
managed by the parent BOM)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson2</artifactId>
</dependency>
Step 3 — fix the import
Change org.springframework.boot.jackson.JsonComponent to
org.springframework.boot.jackson2.JsonComponent. Nothing else in the
file changes.
Step 4 — build and run again
$ mvn -q clean spring-boot:run
Expected: starts clean, no errors, no warnings about the JsonComponent.
Whatever you were expecting is not what you're about to see.
Step 5 — hit it
$ curl -s localhost:8080/accounts/1/balance
Expected if the fix worked: "12.50 EUR"
What you actually get: {"amount":12.50,"currency":"EUR"}That last line is the confirmation signal for the real bug. No exception, no log line, no red text anywhere. The endpoint returns valid JSON, just not the JSON your serializer wrote. If you check /actuator/beans you'll even find moneyJsonComponent sitting there, fully registered, exactly where it should be. The bean is fine. It's just not the bean doing the work.
One environment-specific trap worth knowing about before you spend an hour on it: if you write a @JsonTest for this, Boot 4 autowires both a JacksonTester<Money> and a deprecated Jackson2Tester<Money> into the same test class without complaint. Autowire the wrong one and your test asserts against Jackson 2's output while production traffic goes out through Jackson 3. The test passes. The bug ships anyway.
The one-paragraph version
Spring Boot 4 ships with Jackson 3 as its default, always-on JSON engine, pulled in transitively the moment you add spring-boot-starter-webmvc or spring-boot-starter-json. Jackson 2 support still exists, but it's now a separate, deprecated, opt-in module, and even when you add it back, Boot's HTTP message converter configuration still prefers Jackson 3 by default. Your old @JsonComponent, once you've fixed the import, registers correctly as a Spring bean. It's just registered against the Jackson module that Boot's HTTP layer isn't using anymore.
Two @Imports, one quiet winner
Here's where I actually went looking, because "prefers Jackson 3" isn't good enough to explain behavior you're debugging at 4pm. Boot's HttpMessageConvertersAutoConfiguration imports two configuration classes for JSON, in this order:
@Import({ JacksonHttpMessageConvertersConfiguration.class, Jackson2HttpMessageConvertersConfiguration.class,
GsonHttpMessageConvertersConfiguration.class, JsonbHttpMessageConvertersConfiguration.class,
KotlinSerializationHttpMessageConvertersConfiguration.class })
public final class HttpMessageConvertersAutoConfiguration {
static final String PREFERRED_MAPPER_PROPERTY = "spring.http.converters.preferred-json-mapper";
// ...
}JacksonHttpMessageConvertersConfiguration, no "2" in the name, this is the Jackson 3 side, registers its converter customizer under one condition: a tools.jackson.databind.json.JsonMapper bean has to exist. It does, because JacksonAutoConfiguration (also new in 4.0.0) hands you one unconditionally whenever Jackson 3 is on the classpath:
@AutoConfiguration
@ConditionalOnClass(JsonMapper.class)
public final class JacksonAutoConfiguration {
@Bean
@Primary
@ConditionalOnMissingBean
JsonMapper jacksonJsonMapper(JsonMapper.Builder builder) {
return builder.build();
}
// ...
}spring-boot-starter-webmvc depends on spring-boot-starter-jackson, which depends on the spring-boot-jackson module (Jackson 3). So that JsonMapper bean exists in every default Boot 4 web app whether you asked for Jackson 3 or not. The Jackson 3 converter customizer always gets created, and its own @ConditionalOnProperty has matchIfMissing = true. "Prefer me unless told otherwise" is the actual default, not a fallback.
Jackson2HttpMessageConvertersConfiguration, the one that would wire up your restored @JsonComponent, has a much narrower gate:
@Conditional(PreferJackson2OrJacksonUnavailableCondition.class)
static class MappingJackson2HttpMessageConverterConfiguration {
// ...
}
private static class PreferJackson2OrJacksonUnavailableCondition extends AnyNestedCondition {
@ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY, havingValue = "jackson2")
static class Jackson2Preferred { }
@ConditionalOnMissingBean(JacksonJsonHttpMessageConvertersCustomizer.class)
static class JacksonUnavailable { }
}Read that condition straight. Jackson 2 only wins the HTTP layer if you explicitly set the property to jackson2, or if Jackson 3's customizer bean is missing entirely. In a default spring-boot-starter-webmvc app it is never missing. So adding spring-boot-jackson2 back to your classpath satisfies the compiler and gets your @JsonComponent registered as a bean, and does precisely nothing for what actually goes out over HTTP. That decision was already made one configuration class earlier, by a condition your @JsonComponent isn't even part of.
I want to be fair to the migration guide here, because it's not wrong, it's just easy to read as more complete than it is. It tells you, correctly, that adding spring-boot-jackson2 lets "a Jackson 2 ObjectMapper be used alongside Boot's auto-configuration for Jackson 3." That sentence is true. It does not say which one wins when both are configured for the same job. The answer, Jackson 3, always, unless you flip a property that isn't mentioned anywhere near that paragraph, only shows up if you go read Spring's own regression tests. That's exactly where I found it: a smoke test literally named spring-boot-smoke-test-jackson2-mixed, built to prove this exact interaction, with spring.http.converters.preferred-json-mapper=jackson2 sitting in its application.properties as the thing that makes Jackson 2 win at all.
Making Jackson 2 win again, on purpose
There are two legitimate fixes here, and they're not the same fix wearing different syntax. They commit you to different things.
Fix one: finish the migration. Rewrite the component against Jackson 3's types instead of restoring the old ones.
package com.example.repro;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueSerializer;
import org.springframework.boot.jackson.JacksonComponent;
@JacksonComponent
public class MoneyJacksonComponent {
public static class Serializer extends ValueSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException {
gen.writeString(value.amount() + " " + value.currency());
}
}
}Drop the spring-boot-jackson2 dependency, delete the old MoneyJsonComponent, and curl returns "12.50 EUR" with nothing else changed. This works because it stops fighting the default instead of trying to override it.
Fix two: actually make Jackson 2 win, if you genuinely can't rewrite yet. Keep both the module and the old component, and add the one line the migration guide doesn't show you next to the dependency snippet:
spring.http.converters.preferred-json-mapper=jackson2Now PreferJackson2OrJacksonUnavailableCondition matches on the explicit property instead of the missing-bean fallback, MappingJackson2HttpMessageConverterConfiguration activates, and your original @JsonComponent (imported from org.springframework.boot.jackson2 now) is what actually serializes the response.
Know what fix two costs you. It's an application-wide switch, not a per-endpoint one. Every controller in the app goes back to Jackson 2, including anything you've already written against Jackson 3's JsonMapper directly. Jackson2HttpMessageConvertersConfiguration and Jackson2AutoConfiguration are both marked @Deprecated(since = "4.0.0", forRemoval = true), and the migration guide says flatly that the module is a stop-gap slated for removal in 4.3.0. Reaching for the property buys you time. It doesn't buy you out of the rewrite. It just moves the deadline to whenever your team ships a Boot 4.3 upgrade.
What I'd tell a team starting this migration today
Don't restore @JsonComponent as step one. Grep for every @JsonComponent, every @Bean ObjectMapper, and every place that autowires com.fasterxml.jackson.databind.ObjectMapper before you touch the parent version, and treat that grep result as the actual migration task, not a cleanup item for later. Rewriting a handful of serializers against ValueSerializer and JacksonComponent is usually a smaller job than it looks, and it's a strictly smaller job than carrying a compatibility shim you already know is getting removed.
If a genuine third-party dependency forces Jackson 2 onto your classpath, an SDK, an older internal library, anything you don't control, that's a real reason to reach for spring-boot-jackson2 and the property. Do it as a named, tracked decision with the 4.3 removal date attached to it, not as the thing that happened because the compiler stopped complaining.
Which Boot versions this hits, and how to catch it in CI
This is Boot 4.0.0 and later, full stop. There's no dual-Jackson world before that. Boot 3.5 and earlier auto-configure Jackson 2 exclusively and none of this exists. It doesn't fade out in 4.1 either. The mechanism described above is the permanent shape of the framework, not a bug that gets patched. The one date that matters is 4.3.0, when spring-boot-jackson2 and its auto-configuration are slated for removal entirely. After that, preferred-json-mapper=jackson2 has nothing left to select.
For catching this before it reaches a reviewer: a single @SpringBootTest(webEnvironment = RANDOM_PORT) test that hits the real endpoint through RestTestClient and asserts on the actual JSON body would have caught this in about four lines. @JsonTest alone won't, for the reason above. If you're carrying the Jackson 2 compatibility module on purpose, an architecture test asserting that spring.http.converters.preferred-json-mapper is set to jackson2 somewhere in your configuration isn't paranoid. It's cheap insurance against someone removing that one property line during a later refactor and silently flipping your entire API back to Jackson 3.
The five things worth remembering
@JsonComponent didn't disappear in Boot 4, it moved to org.springframework.boot.jackson2 and Jackson 3 took its old name and package. A clean compile after adding spring-boot-jackson2 tells you nothing about which Jackson engine is actually serializing your HTTP responses. Boot 4 prefers Jackson 3 for JSON conversion by default even when a Jackson 2 ObjectMapper bean is present and correctly configured. spring.http.converters.preferred-json-mapper=jackson2 is the property that actually flips HTTP conversion back, and it isn't next to the dependency snippet in the migration guide. And spring-boot-jackson2 has a removal date, 4.3.0, so treat it as a bridge, not a destination.
Sources
- Spring Boot 4.0 Migration Guide, the "Upgrading Jackson" and "Jackson 2 Compatibility" sections
spring-boot-smoke-test-jackson2-mixed, Spring Boot's own regression test for mixed Jackson 2/3 apps: source on GitHubHttpMessageConvertersAutoConfiguration,JacksonHttpMessageConvertersConfiguration, andJackson2HttpMessageConvertersConfiguration: module/spring-boot-http-converterJacksonAutoConfigurationandJacksonComponent: module/spring-boot-jacksonJackson2AutoConfigurationand the deprecatedJsonComponent: module/spring-boot-jackson2- Checked for existing coverage of this specific angle: Jackson 3 in Spring Boot 4 (Dan Vega) and Spring Boot 4 & Jackson migration guide (Björn Wilmsmann), both cover the package and API rename but not the
spring-boot-jackson2plus preferred-mapper interaction - Stack Overflow's
spring-bootHot tag page and the Stack Exchange API were both unreachable when this was written, blocked at the network layer for the tenth run in a row. This topic was sourced entirely from the official migration guide and Spring Boot's own source and test suite