Building REST APIs with Spring Boot

Content Negotiation & Jackson

18 min Lesson 8 of 13

Content Negotiation & Jackson

Every time a Spring Boot REST controller returns a Java object, something has to convert it into bytes on the wire. That something is Jackson — the JSON library that Spring Boot auto-configures the moment you add spring-boot-starter-web. Understanding how Jackson serializes and deserializes your objects, and how to bend it to your API's exact needs, is a skill you will use on every project.

What Is Content Negotiation?

Content negotiation is the HTTP mechanism by which a client and server agree on the format of the response. The client expresses preference via the Accept request header (e.g. Accept: application/json or Accept: application/xml). The server picks the best matching format it can produce and declares it in the Content-Type response header.

Spring MVC's ContentNegotiationManager sits in front of every @RestController method. It inspects the Accept header, enumerates the HttpMessageConverter beans registered in the application context, and selects the converter that can both handle the return type and produce the requested media type. Jackson's converter, MappingJackson2HttpMessageConverter, handles application/json (and application/*+json). If no converter matches, Spring returns 406 Not Acceptable.

Default behaviour in Spring Boot: With only spring-boot-starter-web on the classpath, JSON is the only auto-configured format. Adding jackson-dataformat-xml (or spring-boot-starter-web + jackson-dataformat-xml) transparently enables XML negotiation alongside JSON — zero extra configuration.

How Jackson Serializes a Java Object

Jackson's ObjectMapper walks a Java object by reflection (or, in newer versions, via bytecode generation) and emits JSON. By default it includes every public getter as a JSON field, using the property name derived from the getter method name. A field getUserName() becomes "userName" in the output.

Consider this entity:

package com.example.api.model; import java.time.LocalDate; import java.util.List; public class Employee { private Long id; private String firstName; private String lastName; private String email; private LocalDate hireDate; private List<String> roles; // standard getters and setters ... }

A controller returning an Employee produces, by default:

{ "id": 42, "firstName": "Leila", "lastName": "Nasser", "email": "leila@example.com", "hireDate": [2023, 3, 15], "roles": ["DEVELOPER", "REVIEWER"] }

Notice hireDate renders as an array. That is Jackson's default for java.time.LocalDate — almost never what you want. The fix is covered below.

Jackson Annotations You Will Use Daily

@JsonProperty — override the JSON key for a single field:

@JsonProperty("first_name") private String firstName;

@JsonIgnore — exclude a field from both serialization and deserialization:

@JsonIgnore private String passwordHash;

@JsonInclude — suppress null fields (or empty collections) from the output, keeping responses lean:

import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_NULL) public class ApiResponse<T> { private T data; private String error; // omitted when null private String message; }

@JsonFormat — control how dates and numbers are serialized:

import com.fasterxml.jackson.annotation.JsonFormat; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private LocalDate hireDate;

Now hireDate renders as "2023-03-15" instead of the array. This is the most common fix for date issues.

@JsonNaming — apply a naming strategy to every property of a class at once, without annotating each field individually:

import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public class Employee { private String firstName; // serialized as "first_name" private String lastName; // serialized as "last_name" }

Configuring Jackson Globally via application.properties

Most Jackson settings can be toggled through Spring Boot's spring.jackson.* property namespace, which avoids writing any Java config:

# application.properties # Use snake_case for all property names globally spring.jackson.property-naming-strategy=SNAKE_CASE # Omit null fields from every response body spring.jackson.default-property-inclusion=non_null # Serialize LocalDate/LocalDateTime as ISO strings, not arrays spring.jackson.serialization.write-dates-as-timestamps=false # Indent output for readability (turn off in production for smaller payloads) spring.jackson.serialization.indent-output=true # Do NOT fail when the JSON contains a field the Java class does not have # (safe for forward-compatible APIs) spring.jackson.deserialization.fail-on-unknown-properties=false
Prefer application.properties for global settings. Annotation-level overrides (@JsonFormat, @JsonProperty) are for exceptions to that global policy. This keeps your domain classes clean and centralizes the serialization strategy where it is easy to audit and change.

Registering a Custom ObjectMapper Bean

When you need programmatic control — registering a custom serializer, enabling a feature that has no property equivalent, or configuring a JavaTimeModule manually — declare an ObjectMapper (or Jackson2ObjectMapperBuilder) bean:

package com.example.api.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class JacksonConfig { @Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); // Register the module that handles java.time types mapper.registerModule(new JavaTimeModule()); // Write dates as ISO strings, not timestamps mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } }
Spring Boot auto-configuration backs off when it detects an ObjectMapper bean in the context. Your bean fully replaces the default one, so make sure to re-apply any settings you relied on implicitly (e.g. registering JavaTimeModule for java.time support).

Writing a Custom Serializer

Sometimes you need serialization logic that no annotation can express — for example, formatting a BigDecimal as a currency string, or masking part of a sensitive field. Extend JsonSerializer<T>:

package com.example.api.serializer; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import java.io.IOException; import java.math.BigDecimal; public class CurrencySerializer extends JsonSerializer<BigDecimal> { @Override public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider provider) throws IOException { // Always emit two decimal places: 1500 -> "1500.00" gen.writeString(value.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString()); } }

Register it on the field:

import com.fasterxml.jackson.databind.annotation.JsonSerialize; @JsonSerialize(using = CurrencySerializer.class) private BigDecimal salary;

Controlling Deserialization: Handling Unknown Fields

By default Jackson throws UnrecognizedPropertyException when an incoming JSON body contains a field your DTO does not declare. This is safe but brittle — it breaks your API every time a client sends a newer payload. The production-ready approach is to ignore unknown fields globally:

# application.properties spring.jackson.deserialization.fail-on-unknown-properties=false

Or at class level for specific DTOs:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @JsonIgnoreProperties(ignoreUnknown = true) public class CreateEmployeeRequest { private String firstName; private String lastName; private String email; }
Do not use your JPA entity classes as request/response bodies directly. Exposing entities couples your API contract to your database schema. Instead, use dedicated DTO classes (like CreateEmployeeRequest and EmployeeResponse) and map between them. This protects against mass-assignment vulnerabilities and lets your schema evolve independently of your API surface.

Summary

Spring Boot wires Jackson as the default JSON engine for all REST controllers. You control its output at three levels: globally via spring.jackson.* properties, per-class via annotations like @JsonNaming and @JsonInclude, and per-field via @JsonProperty, @JsonIgnore, @JsonFormat, and custom serializers. Always use dedicated DTO classes rather than exposing JPA entities, configure write-dates-as-timestamps=false to get readable ISO dates, and disable fail-on-unknown-properties for a more resilient API contract. In the next lesson you will apply these skills to API versioning and REST best practices.