How Spring Boot Serialises JSON

Follow a request body and a return value through Spring MVC's message converters and the single JsonMapper that Spring Boot configures for you.

Syntax@PostMapping("/items") ItemResponse create(@RequestBody CreateItemRequest body) // JSON in, JSON out via JsonMapper

You never call a JSON library in a Spring Boot controller. You declare a parameter with @RequestBody and return an object, and Spring MVC hands both to an HttpMessageConverter. For JSON that converter is JacksonJsonHttpMessageConverter, and it delegates to a Jackson 3 JsonMapper.

One mapper for the whole application

spring-boot-starter-webmvc pulls in spring-boot-starter-jackson, whose auto-configuration builds one JsonMapper bean (named jacksonJsonMapper) from your spring.jackson.* properties and any customiser beans. The MVC converter uses that bean, and so should your own code: inject JsonMapper rather than building a new one, or your Kafka payloads and audit logs will be written with different rules from your HTTP responses.

What gets written

Jackson finds properties through record components, public getters and public fields. A private field without a getter is invisible. For a record, the components are written in declaration order. For an ordinary class, Jackson 3 sorts properties alphabetically by default, which is a change from Jackson 2 and can surprise you when you diff responses after an upgrade.

A few defaults you will meet straight away in Spring Boot 4:

  • Unknown properties in a request body are ignored, so a client that sends "colour" to a DTO without that field gets no error.
  • java.time values are written as ISO-8601 strings such as "2026-03-01T10:15:30Z", with no extra module needed.
  • null values are written as null unless you change the inclusion rule.
  • A missing or null value for a primitive such as int fails the read instead of silently becoming 0.

When it goes wrong

Broken input (malformed JSON, a string where a number belongs, a missing primitive) surfaces as HttpMessageNotReadableException, which Spring MVC answers with 400. Failures on the way out are worse: they happen after your code succeeded and become a 500. The usual cause is returning a JPA entity, where Jackson walks into lazy associations or a bidirectional relationship. Return a record built for the response instead; it keeps the JSON stable when the table changes.

The Content-Type of the request decides which converter reads the body, and the Accept header decides which one writes the response. A request without Content-Type: application/json gets a 415, not a parse error.

Example

Example · java
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

record CreateProductRequest(String name, BigDecimal price) {}

record ProductResponse(long id, String name, BigDecimal price, Instant updatedAt) {}

@RestController
@RequestMapping("/api/products")
public class ProductController {

    // The return value is written by JacksonJsonHttpMessageConverter
    // using the JsonMapper bean that Spring Boot configured.
    @GetMapping
    List<ProductResponse> list() {
        return List.of(new ProductResponse(1, "Desk lamp", new BigDecimal("39.90"),
                Instant.parse("2026-03-01T10:15:30Z")));
    }

    // The body is read by the same converter before this method runs.
    // Malformed JSON never reaches this code: the client gets a 400.
    @PostMapping
    ProductResponse create(@RequestBody CreateProductRequest request) {
        return new ProductResponse(2, request.name(), request.price(), Instant.now());
    }
}

When to use it

  • Return a list of products from a GET endpoint and have it written as a JSON array without any manual conversion.
  • Write audit records to a log table as JSON using exactly the same rules as the public API.
  • Work out why a client's request with a misspelt field name succeeded but stored a null value.

More examples

What travels over the wire

The unknown <code>colour</code> property is ignored rather than rejected, and the <code>Instant</code> comes back as an ISO-8601 string in UTC. Both are Spring Boot 4 defaults. If you need clients to hear about typos in field names, turn on strict reading in the mapper configuration lesson.

Example · http
POST /api/products HTTP/1.1
Content-Type: application/json
Accept: application/json

{"name": "Desk lamp", "price": 39.90, "colour": "black"}

HTTP/1.1 200
Content-Type: application/json

{"id":2,"name":"Desk lamp","price":39.90,"updatedAt":"2026-10-13T09:12:44.512Z"}

Using the same mapper outside a controller

Injecting the <code>JsonMapper</code> bean gives you every setting from <code>application.properties</code> and every registered module. <code>writeValueAsString</code> throws the unchecked <code>JacksonException</code> in Jackson 3, so there is no <code>throws</code> clause to propagate.

Example · java
import org.springframework.stereotype.Service;
import tools.jackson.databind.json.JsonMapper;

record AuditEntry(String actor, String action, String target) {}

@Service
public class AuditWriter {

    private final JsonMapper jsonMapper;

    AuditWriter(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }

    String toJson(AuditEntry entry) {
        // Same naming, inclusion and date rules as the HTTP responses.
        return jsonMapper.writeValueAsString(entry);
    }
}

Checking the JSON shape in a slice test

<code>@JsonTest</code> starts only the Jackson auto-configuration, so the test runs in a fraction of a second and uses the same mapper settings as production. Pin the JSON contract this way for any DTO that external clients depend on.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;

@JsonTest
class ProductJsonTest {

    record ProductResponse(long id, String name, BigDecimal price, Instant updatedAt) {}

    @Autowired
    JacksonTester<ProductResponse> json;

    @Test
    void writesInstantAsIsoString() throws Exception {
        var product = new ProductResponse(1, "Desk lamp", new BigDecimal("39.90"),
                Instant.parse("2026-03-01T10:15:30Z"));

        assertThat(json.write(product))
                .extractingJsonPathStringValue("$.updatedAt")
                .isEqualTo("2026-03-01T10:15:30Z");
    }
}

Discussion

  • Be the first to comment on this lesson.