A comprehensive, hands-on tutorial exploring Jackson JSON processing in Spring Boot 4.0. Learn how to control
JSON serialization and deserialization, leverage Jackson 3's new features, configure JsonMapper,
and implement real-world filtering patterns with @JsonView and the new hint() method.
If you want to learn more about these changes, you can read this article by Sébastien Deleuze.
- Jackson 2 & 3 Compatibility - Understand why this project uses Jackson 3.0.1 (databind) with jackson-annotations 2.20, and how Spring Boot 4.0 bridges both versions for seamless ecosystem migration.
- Jackson 3 Key Changes - Explore the major improvements: new
tools.jacksonpackages, immutable builder-based configuration, ISO-8601 date serialization by default, and unchecked exceptions that work seamlessly with lambdas and streams. - JsonMapper: Reading and Writing JSON - Learn how Spring Boot auto-configures JsonMapper beans, use the TypeReference pattern for reading JSON from files, serialize objects with configured settings, and optionally customize with builder patterns.
- Application Properties Deep Dive - Master Jackson configuration properties like
use-jackson2-defaultsfor migration compatibility,indent-outputfor pretty-printing, understand whysort-properties-alphabeticallydoesn't work with records, and discover other common serialization/deserialization features. - JSON Views - Eliminate DTO proliferation by creating multiple JSON representations from a single model using
@JsonView. Learn hierarchical view design, server-side response filtering, client-side request filtering with the newhint()method, and why it's superior to the oldMappingJacksonValuewrapper approach.
This project uses Jackson 3.0.1 (databind) with jackson-annotations 2.20 - and yes, that version mismatch is intentional!
The Jackson team solved a critical compatibility problem: how to support both Jackson 2 and Jackson 3 simultaneously during the ecosystem's transition period.
The Solution:
- Jackson Core 3.0.1: New
tools.jacksonpackages, improved APIs, thread-safe builders - jackson-annotations 2.20: Shared annotation library that works with BOTH Jackson 2 and 3
This means:
- Your
@JsonView,@JsonFormat, and other annotations work identically across Jackson 2 and 3 - Organizations can gradually migrate from Jackson 2 to 3 without breaking shared domain models
- Spring Boot 4.0 uses Jackson 3's processing engine while maintaining full annotation compatibility
Dependency Tree:
spring-boot-starter-jackson (4.0.0-RC1)
├── tools.jackson.core:jackson-databind:3.0.1 ← Jackson 3 engine
└── com.fasterxml.jackson.core:jackson-annotations:2.20 ← Shared annotationsThis is the permanent solution, not a transitional state. It ensures maximum compatibility across the Java ecosystem.
If you're coming from Jackson 2, here are the important changes:
// Jackson 2
import com.fasterxml.jackson.databind.ObjectMapper;
// Jackson 3
import tools.jackson.databind.json.JsonMapper;Jackson 3 requires immutable, thread-safe configuration:
// Jackson 2 - Mutable (not recommended)
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT); // Mutable state!
// Jackson 3 - Immutable Builder Pattern
JsonMapper mapper = JsonMapper.builder()
.enable(SerializationFeature.INDENT_OUTPUT)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.build(); // Thread-safe and immutable// Jackson 2 default (WRITE_DATES_AS_TIMESTAMPS = true)
{"bakedAt": 1699257000000}
// Jackson 3 default (WRITE_DATES_AS_TIMESTAMPS = false)
{"bakedAt": "2025-11-06T05:30:00"}Jackson 3 defaults to ISO-8601 strings - more human-readable and easier for frontend frameworks to parse.
Jackson 3 switches from checked to unchecked exceptions - a significant improvement for modern Java development.
// Jackson 2 - Checked exceptions (forced handling)
try {
objectMapper.readValue(json, MyClass.class);
} catch (JsonProcessingException e) { // Checked - must catch
} catch (IOException e) { // Checked - must catch
}
// Jackson 3 - Unchecked exceptions (optional handling)
try {
jsonMapper.readValue(json, MyClass.class);
} catch (JacksonException e) { // Unchecked RuntimeException - catch if needed
// Single exception hierarchy
}Why the change?
Jackson 2's checked exceptions were inherited from IOException, reflecting the historical view that I/O operations
should force error handling. However, this created problems in modern Java:
- Lambda compatibility issues: Checked exceptions can't be thrown from lambda expressions
- Stream API friction: Requires awkward wrapping in
try-catchor helper methods - Unnecessary boilerplate: Most developers just rethrow or wrap them anyway
- Runtime nature: JSON parsing errors are typically unrecoverable runtime failures, not business logic that should be handled
Benefits of unchecked exceptions:
// Jackson 2 - Doesn't compile! Checked exceptions in lambdas
donuts.stream()
.map(d -> jsonMapper.writeValueAsString(d)) // ❌ Compile error
.toList();
// Jackson 3 - Works seamlessly
donuts.stream()
.map(d -> jsonMapper.writeValueAsString(d)) // ✅ No problem
.toList();- Works in lambdas and streams: No need for wrapper methods or sneaky throws
- Cleaner code: Only catch exceptions where you can actually handle them
- Single exception hierarchy: All Jackson exceptions extend
JacksonException - Still catchable: You can still catch and handle when needed
- Modern Java alignment: Follows contemporary exception handling patterns
Spring Boot 4.0 Transition: Use spring.jackson.use-jackson2-defaults: true in application.yaml to maintain Jackson 2 behavior during migration.
Spring Boot 4.0 auto-configures a JsonMapper bean that you can inject and use throughout your application for reading and writing JSON.
Spring Boot automatically creates a JsonMapper bean configured from your application.yaml properties. No explicit @Bean configuration needed!
@Component
public class DataLoader implements ApplicationRunner {
private final JsonMapper jsonMapper; // Auto-configured by Spring Boot
private final ResourceLoader resourceLoader;
public DataLoader(JsonMapper jsonMapper, ResourceLoader resourceLoader) {
this.jsonMapper = jsonMapper;
this.resourceLoader = resourceLoader;
}
// ... use the jsonMapper
}The DataLoader class demonstrates reading JSON data from a file (src/main/resources/data/donuts-menu.json):
@Override
public void run(ApplicationArguments args) throws Exception {
Resource resource = resourceLoader.getResource("classpath:data/donuts-menu.json");
// Read JSON into List<Donut> using TypeReference
this.donuts = jsonMapper.readValue(
resource.getInputStream(),
new TypeReference<List<Donut>>() {} // Preserves generic type info
);
log.info("Loaded {} donuts from JSON file", donuts.size());
}Why TypeReference?
- Java's type erasure means
List<Donut>becomes justListat runtime TypeReferencecaptures the full generic typeList<Donut>- Jackson 3 uses the diamond operator
<>- type is inferred from the variable
You can serialize objects back to JSON strings:
// Pretty-print a single donut (uses indent-output: true from application.yaml)
String json = jsonMapper.writeValueAsString(donuts.getFirst());
log.info("Serialized donut:\n{}", json);Output:
{
"type" : "Classic Glazed",
"glaze" : "VANILLA",
"toppings" : [ ],
"price" : "2.50",
"isVegan" : false,
"calories" : 260,
"bakedAt" : "2025-11-06T05:30:00"
}If you need custom behavior beyond application.yaml properties, you can define your own bean:
@Configuration
public class JacksonConfig {
@Bean
public JsonMapper jsonMapper() {
return JsonMapper.builder()
.enable(SerializationFeature.INDENT_OUTPUT)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
}
}Note: This project uses Spring Boot's auto-configuration to keep the configuration in application.yaml for simplicity.
This project's application.yaml demonstrates key Jackson configuration options:
spring:
application:
name: donuts
jackson:
use-jackson2-defaults: true # Backward compatibility
serialization:
indent-output: true # Pretty-print JSON
# mapper:
# sort-properties-alphabetically: false # Commented - see belowPurpose: Maintains Jackson 2 behavior during the transition to Jackson 3.
Why needed:
- Jackson 3 changed some defaults (like date serialization)
- This setting ensures consistent behavior if you're migrating from Jackson 2
- Recommended for Spring Boot 4.0 during transition period
When to remove: Once you've validated all Jackson 3 changes work for your use case.
Purpose: Pretty-prints JSON output with proper indentation.
Maps to: SerializationFeature.INDENT_OUTPUT
Effect:
// With indent-output: true
{
"type" : "Maple Bar",
"price" : "3.99"
}
// With indent-output: false
{"type":"Maple Bar","price":"3.99"}Best for: Development, debugging, human-readable APIs. Disable in production for smaller payloads.
Why commented out? This property has a known limitation in Jackson 3:
The Issue:
SORT_PROPERTIES_ALPHABETICALLYdoesn't work with Java records or classes with parameterized constructors- Jackson 3 prioritizes constructor parameter order over alphabetical sorting
- Since this project uses records (
public record Donut(...)), alphabetical sorting doesn't work
Example:
public record Donut(
String type, // Order: 1
Glaze glaze, // Order: 2
// ... follows constructor parameter order
) {}Output follows constructor order, not alphabetical order.
Workaround: Use @JsonPropertyOrder(alphabetic = true) on regular classes (not records) if you need alphabetical ordering.
While not used in this project, these are frequently useful:
Serialization:
spring:
jackson:
serialization:
fail-on-empty-beans: false # Allow serializing objects with no properties
write-dates-as-timestamps: false # ISO-8601 strings (Jackson 3 default)Deserialization:
spring:
jackson:
deserialization:
fail-on-unknown-properties: false # Ignore unknown JSON fields
accept-empty-string-as-null-object: true # Treat "" as nullDate Handling in Jackson 3:
- Jackson 2 default:
"bakedAt": 1699257000000(numeric timestamp) - Jackson 3 default:
"bakedAt": "2025-11-06T05:30:00"(ISO-8601 string) - More human-readable and easier for JavaScript/frontend frameworks
Imagine you're building a Donut API that needs to serve different clients with different data requirements:
- Mobile App (bandwidth-constrained): Just
typeandprice - Public Website: Add
glaze,toppings, andisVegan - Internal Dashboard: Everything, including
caloriesandbakedAt - Admin Panel: All fields plus system metadata
Traditional Approach Problems:
// ❌ Multiple DTOs for every endpoint
public class DonutSummaryDTO { ... }
public class DonutPublicDTO { ... }
public class DonutInternalDTO { ... }
public class DonutAdminDTO { ... }
// ❌ Multiple controllers or tons of mapping code
@GetMapping("/summary")
public List<DonutSummaryDTO> getSummary() {
return donuts.stream()
.map(d -> new DonutSummaryDTO(d.getType(), d.getPrice()))
.toList();
}
@GetMapping("/public")
public List<DonutPublicDTO> getPublic() {
return donuts.stream()
.map(d -> new DonutPublicDTO(d.getType(), d.getGlaze(), /* ... */))
.toList();
}
// ... multiply by every API endpoint!This creates:
- DTO class proliferation
- Repetitive mapping code
- Maintenance nightmares
- Easy to make mistakes and expose wrong data
One model, multiple views:
public record Donut(
@JsonView(Views.Summary.class) // Mobile: type and price
String type,
@JsonView(Views.Public.class) // Web: + glaze, toppings, isVegan
Glaze glaze,
@JsonView(Views.Public.class)
List<String> toppings,
@JsonView(Views.Summary.class)
BigDecimal price,
@JsonView(Views.Public.class)
boolean isVegan,
@JsonView(Views.Internal.class) // Internal: + calories, bakedAt
Integer calories,
@JsonView(Views.Internal.class)
LocalDateTime bakedAt
) { }View hierarchy:
public class Views {
public interface Summary {} // type, price
public interface Public extends Summary {} // + glaze, toppings, isVegan
public interface Internal extends Public {} // + calories, bakedAt
public interface Admin extends Internal {} // all fields
}One controller, multiple endpoints:
@GetMapping("/api/donuts/summary")
@JsonView(Views.Summary.class)
public List<Donut> getSummary() {
return dataLoader.getDonuts(); // Same data, different view!
}
@GetMapping("/api/donuts/public")
@JsonView(Views.Public.class)
public List<Donut> getPublic() {
return dataLoader.getDonuts(); // Same data, different view!
}Result: Clean, maintainable, and difficult to accidentally expose sensitive data.
Before Spring Boot 4.0, if you wanted to send a request with filtered fields, you needed this awkward wrapper:
// ❌ OLD: Required mutable wrapper object
var user = new User("Marcel", "Martin", LocalDate.of(1971, 7, 12),
"m@m.fr", "1234 rue Gambetta", 69002, "Lyon", "France");
var jacksonValue = new MappingJacksonValue(user); // Wrapper!
jacksonValue.setSerializationView(Summary.class); // Mutable!
var response = this.restTemplate.postForObject(
"http://localhost:8080/create",
jacksonValue, // Have to send wrapper, not the actual object
String.class
);Problems:
- Extra wrapper object (
MappingJacksonValue) - Mutable state (not thread-safe)
- Breaks fluent API style
- Not intuitive
With Spring Boot 4.0 and Jackson 3, you can use the new hint() method:
// ✅ NEW: Clean, immutable, fluent API
var user = new User("Marcel", "Martin", LocalDate.of(1971, 7, 12),
"m@m.fr", "1234 rue Gambetta", 69002, "Lyon", "France");
var response = this.restClient.post()
.uri("http://localhost:8080/create")
.hint(JsonView.class.getName(), Summary.class) // Clean hint!
.body(user) // Send the actual object
.retrieve()
.body(String.class);Benefits:
- No wrapper objects
- Immutable and thread-safe
- Fluent API maintained
- Works with Jackson 3's
SmartHttpMessageConverter
- Java 21+
- Maven 3.6+
- Spring Boot 4.0.0-RC1
Terminal 1 - Start the Server:
./mvnw spring-boot:runThe server starts on http://localhost:8080 with these endpoints:
GET /api/donuts/summary- Summary view (type, price)GET /api/donuts/public- Public view (+ glaze, toppings, isVegan)GET /api/donuts/internal- Internal view (+ calories, bakedAt)GET /api/donuts/admin- Admin view (all fields)POST /api/donuts- Create donut (accepts only Summary fields)
Terminal 2 - Run the Client:
./mvnw spring-boot:run -Dstart-class=dev.danvega.donuts.ClientAppThe client demonstrates:
- Fetching donuts with GET
- Creating a donut with POST using
hint()to filter what gets sent