Command Palette

Search for a command to run...

[Spring Boot Basics] JSON with Jackson 3 and DTOs in Spring Boot: Serialization, Deserialization and MapStruct

A @RestController method returns a Java object and the client receives JSON. A @RequestBody parameter arrives as JSON and the method receives a Java object. You write neither conversion: spring-boot-starter-webmvc pulled in Jackson, and Spring Boot registered the component that does both jobs. In Spring Boot 4 that Jackson is a new major version, Jackson 3, with new package names, an immutable mapper and defaults that differ from the ones most Spring Boot tutorials were written against.

This article covers both sides of the topic. First Jackson itself: where it sits in a request, what Jackson 3 changes for application code, which defaults Boot 4.1.1 really applies, and the annotations that shape JSON in each direction. Then the question every API meets once Jackson works: which objects should cross the HTTP boundary at all. Returning the domain class leaks fields and accepts fields the client must never set, so the product catalogue moves to request and response DTOs, mapped first by hand and then with MapStruct.

JSON converted by Jackson 3 into a DTO at the boundary, with the entity and its internal fields behind it

Everything below was produced on OpenJDK 21.0.6 with Spring Boot 4.1.1 (Spring Framework 7.0.9, Jackson 3.1.5, embedded Tomcat 11.0.24), Gradle 9.7.1 and MapStruct 1.6.3, on a project generated by Spring Initializr with dependencies=web. The application ran from its jar with --server.port=8118, spring.jackson.* settings were passed as -- arguments in the same command, and every JSON body, log line and compiler message is copied from those runs.

How Spring Boot converts JSON: HttpMessageConverter and JsonMapper

The catalogue keeps its products in memory until databases arrive in Chapter 4. For this article Product is the domain class: three fields a client works with, and three that belong to the business only.

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.time.Instant;
 
public class Product {
 
    private Long id;
    private String name;
    private BigDecimal price;
    private BigDecimal costPrice;
    private Instant createdAt;
    private String internalNotes;
 
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
 
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
 
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
 
    public BigDecimal getCostPrice() { return costPrice; }
    public void setCostPrice(BigDecimal costPrice) { this.costPrice = costPrice; }
 
    public Instant getCreatedAt() { return createdAt; }
    public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
 
    public String getInternalNotes() { return internalNotes; }
    public void setInternalNotes(String internalNotes) { this.internalNotes = internalNotes; }
}

ProductStore keeps products in a ConcurrentHashMap, hands out ids from an AtomicLong, and fills in id and createdAt only when the object does not already have them:

src/main/java/com/example/demo/product/ProductStore.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
 
import org.springframework.stereotype.Component;
 
@Component
public class ProductStore {
 
    private final Map<Long, Product> products = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong();
 
    public ProductStore() {
        save(seed("Mechanical keyboard", "1290000", "870000", "Supplier contract ends in December"));
        save(seed("Wireless mouse", "490000", "310000", "Return rate 4%, watch this one"));
    }
 
    public Product save(Product product) {
        if (product.getId() == null) {
            product.setId(sequence.incrementAndGet());
        }
        if (product.getCreatedAt() == null) {
            product.setCreatedAt(Instant.now().truncatedTo(ChronoUnit.SECONDS));
        }
        products.put(product.getId(), product);
        return product;
    }
 
    public Optional<Product> findById(Long id) {
        return Optional.ofNullable(products.get(id));
    }
 
    private static Product seed(String name, String price, String costPrice, String notes) {
        Product product = new Product();
        product.setName(name);
        product.setPrice(new BigDecimal(price));
        product.setCostPrice(new BigDecimal(costPrice));
        product.setInternalNotes(notes);
        return product;
    }
}

The first version of the controller, written with the binding annotations from the previous article, hands Product straight to the client and takes it straight back:

src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductStore store;
 
    public ProductController(ProductStore store) {
        this.store = store;
    }
 
    @GetMapping("/{id}")
    public Product findById(@PathVariable Long id) {
        return store.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
 
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Product create(@RequestBody Product product) {
        return store.save(product);
    }
}
Bash
curl -s http://localhost:8118/api/products/1
Text
{"costPrice":870000,"createdAt":"2026-09-12T07:05:38Z","id":1,"internalNotes":"Supplier contract ends in December","name":"Mechanical keyboard","price":1290000}

Two details of that line come back later: the keys are in alphabetical order, not in the order the fields are declared, and costPrice and internalNotes reached the client. First, what wrote it.

The Jackson message converter and the JsonMapper bean

A @RestController method's return value becomes the response body through Spring MVC's RequestResponseBodyMethodProcessor. It walks a list of HttpMessageConverters and uses the first one that can write the value's type in a media type the client accepts; a @RequestBody parameter goes through the same list in the other direction. This runner prints the list, together with the Jackson beans in the context:

src/main/java/com/example/demo/JacksonInspector.java
package com.example.demo;
 
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
 
@Component
class JacksonInspector implements ApplicationRunner {
 
    private final ApplicationContext context;
 
    JacksonInspector(ApplicationContext context) {
        this.context = context;
    }
 
    @Override
    public void run(ApplicationArguments args) {
        for (String name : context.getBeanNamesForType(ObjectMapper.class)) {
            System.out.println("mapper bean    : " + name + " -> " + context.getBean(name).getClass().getName());
        }
        for (String name : context.getBeanNamesForType(JsonMapper.Builder.class, true, false)) {
            System.out.println("builder bean   : " + name + " (prototype: " + context.isPrototype(name) + ")");
        }
        RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class);
        for (HttpMessageConverter<?> converter : adapter.getMessageConverters()) {
            System.out.println("converter      : " + converter.getClass().getName());
            if (converter instanceof JacksonJsonHttpMessageConverter json) {
                System.out.println("  same mapper as the bean? " + (json.getMapper() == context.getBean(JsonMapper.class)));
            }
        }
    }
}
Text
mapper bean    : jacksonJsonMapper -> tools.jackson.databind.json.JsonMapper
builder bean   : jsonMapperBuilder (prototype: true)
converter      : org.springframework.http.converter.ByteArrayHttpMessageConverter
converter      : org.springframework.http.converter.StringHttpMessageConverter
converter      : org.springframework.http.converter.ResourceHttpMessageConverter
converter      : org.springframework.http.converter.ResourceRegionHttpMessageConverter
converter      : org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter
converter      : org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
  same mapper as the bean? true
  • JacksonJsonHttpMessageConverter is Spring Framework 7's JSON converter for Jackson 3. MappingJackson2HttpMessageConverter, the Jackson 2 converter Spring Boot 3 registered, still ships in spring-web-7.0.9.jar, annotated @Deprecated(since = "7.0", forRemoval = true).
  • The converter has no configuration of its own. Boot constructs it around the JsonMapper bean named jacksonJsonMapper, and same mapper as the bean? true shows it is that exact instance. Whatever is configured on the bean applies to every JSON request and response body.
  • jsonMapperBuilder is a prototype-scoped JsonMapper.Builder bean: every injection point receives a new builder that already carries Boot's configuration. It matters in the section on customisation.
  • The five converters before Jackson handle byte[], String, resources and form data. A record or a plain class falls through to Jackson.

With logging.level.org.springframework.web=DEBUG, one POST shows both directions:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":650000}' http://localhost:8118/api/products
Text
2026-09-12T14:05:46.917+07:00 DEBUG 37638 --- [demo] [nio-8118-exec-4] o.s.web.servlet.DispatcherServlet        : POST "/api/products", parameters={}
2026-09-12T14:05:46.917+07:00 DEBUG 37638 --- [demo] [nio-8118-exec-4] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#create(Product)
2026-09-12T14:05:46.938+07:00 DEBUG 37638 --- [demo] [nio-8118-exec-4] m.m.a.RequestResponseBodyMethodProcessor : Read "application/json;charset=UTF-8" to [com.example.demo.product.Product@69867be0]
2026-09-12T14:05:46.942+07:00 DEBUG 37638 --- [demo] [nio-8118-exec-4] m.m.a.RequestResponseBodyMethodProcessor : Using 'application/json', given [*/*] and supported [application/json, application/*+json]
2026-09-12T14:05:46.942+07:00 DEBUG 37638 --- [demo] [nio-8118-exec-4] m.m.a.RequestResponseBodyMethodProcessor : Writing [com.example.demo.product.Product@69867be0]
2026-09-12T14:05:46.944+07:00 DEBUG 37638 --- [demo] [nio-8118-exec-4] o.s.web.servlet.DispatcherServlet        : Completed 201 CREATED

Read ... to [...] is the converter deserializing the body into a Product before the method runs. Using 'application/json' is content negotiation picking the response type, and Writing [...] is the same converter serializing the returned object. Product has no toString(), so the log shows only Product@69867be0; the records later in this article print their contents.

The picture follows one POST through the version of this API built by the end of the article, where DTOs stand on both sides of the controller. The converter and the mapper bean play exactly the roles they play above.

Eight numbered steps of one POST: the body passes through JacksonJsonHttpMessageConverter and the jacksonJsonMapper bean into CreateProductRequest, the controller maps it to Product and back to ProductResponse, and the same converter writes the 201 JSON response

What changed with Jackson 3 in Spring Boot 4

Spring Boot 4 moved from Jackson 2 to Jackson 3. ./gradlew dependencies --configuration runtimeClasspath, trimmed to the Jackson lines:

Text
\--- org.springframework.boot:spring-boot-starter-webmvc -> 4.1.1
     +--- org.springframework.boot:spring-boot-starter-jackson:4.1.1
     |    \--- org.springframework.boot:spring-boot-jackson:4.1.1
     |         \--- tools.jackson.core:jackson-databind:3.1.5
     |              +--- com.fasterxml.jackson.core:jackson-annotations:2.21
     |              +--- tools.jackson.core:jackson-core:3.1.5
     |              |    \--- tools.jackson:jackson-bom:3.1.5
     |              |         +--- com.fasterxml.jackson.core:jackson-annotations:2.21 (c)
     |              |         +--- tools.jackson.core:jackson-core:3.1.5 (c)
     |              |         \--- tools.jackson.core:jackson-databind:3.1.5 (c)
     |              \--- tools.jackson:jackson-bom:3.1.5 (*)

spring-boot-starter-webmvc depends on spring-boot-starter-jackson, which brings Boot's Jackson integration, spring-boot-jackson, and jackson-databind 3.1.5. Four changes reach code you write.

tools.jackson packages, com.fasterxml.jackson annotations

Jackson 3 moved its Maven group and its Java packages from com.fasterxml.jackson to tools.jackson: the artifact tools.jackson.core:jackson-databind contains tools.jackson.databind.json.JsonMapper. The annotations stayed where they were. jackson-annotations is still com.fasterxml.jackson.core:jackson-annotations, version 2.21, and Jackson 3 reads the same @JsonProperty, @JsonIgnore and @JsonFormat as before. A class that uses both imports from two roots:

Java
import com.fasterxml.jackson.annotation.JsonFormat;   // annotations: same package as Jackson 2
import tools.jackson.core.JacksonException;            // everything else: tools.jackson
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;

Code written the Jackson 2 way stops compiling. The first class below is copied from a Spring Boot 3 project; the second already uses the new packages but configures the mapper as Jackson 2 allowed:

src/main/java/com/example/demo/LegacyImport.java
package com.example.demo;
 
import com.fasterxml.jackson.databind.ObjectMapper;
 
class LegacyImport {
    ObjectMapper mapper;
}
src/main/java/com/example/demo/MutateMapper.java
package com.example.demo;
 
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
 
class MutateMapper {
    void configure(JsonMapper mapper) {
        mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
    }
}
Bash
./gradlew -q compileJava

The compiler part of the output, with the project directory removed from the paths:

Text
src/main/java/com/example/demo/LegacyImport.java:3: error: package com.fasterxml.jackson.databind does not exist
import com.fasterxml.jackson.databind.ObjectMapper;
                                     ^
src/main/java/com/example/demo/LegacyImport.java:6: error: cannot find symbol
    ObjectMapper mapper;
    ^
  symbol:   class ObjectMapper
  location: class LegacyImport
src/main/java/com/example/demo/MutateMapper.java:8: error: cannot find symbol
        mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
              ^
  symbol:   method configure(SerializationFeature,boolean)
  location: variable mapper of type JsonMapper
3 errors

The first two errors are the package move: com.fasterxml.jackson.databind is not on the classpath at all. The third is the next change.

JsonMapper is immutable and built with a builder

A Jackson 2 ObjectMapper was created first and configured afterwards, with calls such as configure(...), registerModule(...) and setSerializationInclusion(...). Jackson 3's ObjectMapper has none of those methods: configuration is fixed when a mapper is built. JsonMapper, the JSON subclass of ObjectMapper and the type of Boot's bean, comes from JsonMapper.builder(), and rebuild() returns a builder pre-filled with an existing mapper's settings. A standalone program, compiled against the three Jackson jars from the tree above:

JacksonApiDemo.java
import java.time.Instant;
import java.time.LocalDate;
 
import tools.jackson.core.JacksonException;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
 
public class JacksonApiDemo {
 
    record Launch(String product, Instant at, LocalDate day) {}
 
    public static void main(String[] args) {
        JsonMapper mapper = JsonMapper.builder().build();
        Launch launch = new Launch("Mechanical keyboard", Instant.parse("2026-10-17T02:30:00Z"), LocalDate.of(2026, 10, 17));
 
        System.out.println(mapper.writeValueAsString(launch));
 
        JsonMapper pretty = mapper.rebuild()
                .enable(SerializationFeature.INDENT_OUTPUT)
                .build();
        System.out.println(pretty.writeValueAsString(launch));
        System.out.println("original still compact: " + !mapper.isEnabled(SerializationFeature.INDENT_OUTPUT));
 
        Launch back = mapper.readValue("{\"product\":\"Mechanical keyboard\",\"at\":\"2026-10-17T02:30:00Z\",\"day\":\"2026-10-17\"}", Launch.class);
        System.out.println(back);
 
        try {
            mapper.readValue("{\"product\": \"Mechanical keyboard\", \"day\": \"17/10/2026\"}", Launch.class);
        } catch (JacksonException e) {
            System.out.println(e.getClass().getName());
            System.out.println("RuntimeException? " + (e instanceof RuntimeException));
            System.out.println(e.getOriginalMessage());
        }
    }
}
Text
{"product":"Mechanical keyboard","at":"2026-10-17T02:30:00Z","day":"2026-10-17"}
{
  "product" : "Mechanical keyboard",
  "at" : "2026-10-17T02:30:00Z",
  "day" : "2026-10-17"
}
original still compact: true
Launch[product=Mechanical keyboard, at=2026-10-17T02:30:00Z, day=2026-10-17]
tools.jackson.databind.exc.InvalidFormatException
RuntimeException? true
Cannot deserialize value of type `java.time.LocalDate` from String "17/10/2026": Failed to deserialize `java.time.LocalDate` (with format 'Value(Year,4,10,EXCEEDS_PAD)'-'Value(MonthOfYear,2)'-'Value(DayOfMonth,2)'): (java.time.format.DateTimeParseException) Text '17/10/2026' could not be parsed at index 0

rebuild() produced a second, indented mapper, and original still compact: true shows the first one was left untouched.

Jackson 3 exceptions are unchecked

writeValueAsString and readValue in that program sit outside any try, and main declares no throws. In Jackson 2 both methods declared throws JsonProcessingException, and javap on jackson-core 2.21.5 shows why that forced a try or a throws on every caller:

Text
public class com.fasterxml.jackson.core.JsonProcessingException extends com.fasterxml.jackson.core.JacksonException {
public abstract class com.fasterxml.jackson.core.JacksonException extends java.io.IOException {

In Jackson 3.1.5 the base class is unchecked:

Text
public class tools.jackson.core.JacksonException extends java.lang.RuntimeException {
public class tools.jackson.databind.DatabindException extends tools.jackson.core.JacksonException {
public class tools.jackson.databind.exc.MismatchedInputException extends tools.jackson.databind.DatabindException {
public class tools.jackson.databind.exc.InvalidFormatException extends tools.jackson.databind.exc.MismatchedInputException {

The catch (JacksonException e) in the program is therefore optional. It caught an InvalidFormatException for a date in the wrong format, and RuntimeException? true confirmed the type. Controllers rarely call the mapper themselves anyway: a body Jackson cannot read is turned into a 400 before the method runs, as the defaults below show.

java.time works without jackson-datatype-jsr310

The first line the program printed contains an Instant and a LocalDate in ISO-8601 form, from a mapper built with nothing but JsonMapper.builder(). The same Instant through a plain Jackson 2.21.5 ObjectMapper:

Text
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (or disable `MapperFeature.REQUIRE_HANDLERS_FOR_JAVA8_TIMES`)

Spring Boot 3 hid that error because spring-boot-starter-json 3.5.0 depended on jackson-datatype-jsr310, jackson-datatype-jdk8 and jackson-module-parameter-names. In Jackson 3 that support is part of jackson-databind: its jar contains the packages tools.jackson.databind.ext.javatime and tools.jackson.databind.ext.jdk8, parameter name detection is a MapperFeature, and the dependency tree above has no module to add.

Jackson 3 defaults in Spring Boot 4.1.1, measured

To see the defaults in real requests, a throwaway JsonLabController under /lab echoes what it reads and logs the Java object it received. The parts used in this section, with imports omitted:

src/main/java/com/example/demo/lab/JsonLabController.java
@RestController
@RequestMapping("/lab")
public class JsonLabController {
 
    private static final Logger log = LoggerFactory.getLogger(JsonLabController.class);
 
    private static final Instant LISTED = Instant.parse("2026-10-17T02:30:00Z");
 
    public record NewProduct(String name, BigDecimal price, int stockQuantity) {}
 
    @PostMapping("/products")
    public NewProduct create(@RequestBody NewProduct product) {
        log.info("Deserialized {}", product);
        return product;
    }
 
    public record Timestamps(Instant instant, LocalDate localDate, LocalDateTime localDateTime,
            OffsetDateTime offsetDateTime, ZonedDateTime zonedDateTime, Duration duration, Date legacyDate) {}
 
    @GetMapping("/times")
    public Timestamps times() {
        return new Timestamps(LISTED, LocalDate.of(2026, 10, 17), LocalDateTime.of(2026, 10, 17, 9, 30),
                LISTED.atOffset(ZoneOffset.UTC), LISTED.atZone(ZoneId.of("Asia/Ho_Chi_Minh")),
                Duration.ofMinutes(90), Date.from(LISTED));
    }
 
    public static class Supplier {
 
        private final String name;
        private final String country;
 
        public Supplier(String name, String country) {
            this.name = name;
            this.country = country;
        }
 
        public String getName() { return name; }
        public String getCountry() { return country; }
 
        @Override
        public String toString() {
            return "Supplier[name=" + name + ", country=" + country + "]";
        }
    }
 
    @PostMapping("/suppliers")
    public Supplier supplier(@RequestBody Supplier supplier) {
        log.info("Deserialized {}", supplier);
        return supplier;
    }
 
    // the sections below add more endpoints here
}

Dates first:

Bash
curl -s http://localhost:8118/lab/times
Text
{"instant":"2026-10-17T02:30:00Z","localDate":"2026-10-17","localDateTime":"2026-10-17T09:30:00","offsetDateTime":"2026-10-17T02:30:00Z","zonedDateTime":"2026-10-17T09:30:00+07:00","duration":"PT1H30M","legacyDate":"2026-10-17T02:30:00.000Z"}

Every date and time type is an ISO-8601 string, Duration and the legacy java.util.Date included; nothing is a numeric timestamp. ZonedDateTime keeps its offset, +07:00, but not the zone id.

A null for the int:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":650000,"stockQuantity":null}' http://localhost:8118/lab/products
Text
HTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:51 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:03:51.034Z","status":400,"error":"Bad Request","path":"/lab/products"}

The application log says why:

Text
2026-09-12T14:03:51.034+07:00  WARN 36561 --- [demo] [nio-8118-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot map `null` into type `int` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)]

Leaving stockQuantity out of the body entirely produced the same 400 and the same message. Two JSON values in one body, {"name":"USB-C hub","price":650000,"stockQuantity":25}{"name":"Webcam"}, were rejected too:

Text
2026-09-12T14:03:51.055+07:00  WARN 36561 --- [demo] [nio-8118-exec-8] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Trailing token (`JsonToken.START_OBJECT`) found after value (bound as `com.example.demo.lab.JsonLabController$NewProduct`): not allowed as per `DeserializationFeature.FAIL_ON_TRAILING_TOKENS`]

An unknown property, "warehouse":"HN-02", was not: the request returned 200 and the key simply disappeared.

Then the same jar, the same requests, started with --spring.jackson.use-jackson2-defaults=true. Boot's metadata describes the property as "Whether to configure Jackson 3 with the same defaults as Spring Boot previously used for Jackson 2." In JacksonAutoConfiguration it calls Jackson's own configureForJackson2() on the builder, then disables WRITE_DATES_AS_TIMESTAMPS, WRITE_DURATIONS_AS_TIMESTAMPS, FAIL_ON_UNKNOWN_PROPERTIES and DEFAULT_VIEW_INCLUSION. What the two runs returned:

BehaviourSpring Boot 4.1.1 defaultuse-jackson2-defaults=true
Instant, LocalDate, LocalDateTime, OffsetDateTime, ZonedDateTime, DurationISO-8601 strings, as abovethe same strings
java.util.Date"2026-10-17T02:30:00.000Z""2026-10-17T02:30:00.000+00:00"
Unknown property in the bodyignored, 200ignored, 200
null for an int400accepted as 0
int missing from the body400accepted as 0
A second JSON value after the first400, Trailing tokenignored, 200
Stray text after the value (x)400, Unrecognized token 'x'ignored, 200
2.5 for an intaccepted as 2accepted as 2
Property order, class with gettersalphabeticaldeclaration order
Property order, recordcomponent ordercomponent order
Class whose only constructor takes every propertyread through that constructor500, no Creators, like default constructor, exist

For a migration that cannot happen in one step, the 4.1.1 BOM also manages org.springframework.boot:spring-boot-jackson2, a module that registers Jackson2AutoConfiguration, and spring.http.converters.preferred-json-mapper accepts jackson2, a value the 4.1.1 metadata lists as deprecated. This series stays on Jackson 3.

Why the property order changed

With Boot's defaults, the first GET /api/products/1 of this article listed its keys alphabetically. The same request with use-jackson2-defaults=true:

Text
{"id":1,"name":"Mechanical keyboard","price":1290000,"costPrice":870000,"createdAt":"2026-09-12T07:05:39Z","internalNotes":"Supplier contract ends in December"}

Jackson 3 enables MapperFeature.SORT_PROPERTIES_ALPHABETICALLY. When declaration order is the only Jackson 2 behaviour you want back, one property is enough: started with --spring.jackson.mapper.sort-properties-alphabetically=false and nothing else, the application returned {"id":1,"name":"Mechanical keyboard","price":1290000,"costPrice":870000,"createdAt":"2026-09-12T07:36:45Z","internalNotes":"Supplier contract ends in December"}.

Records keep their component order either way. A second feature, SORT_CREATOR_PROPERTIES_FIRST, writes properties that are passed to a constructor first, in parameter order, and a record's canonical constructor takes every component. The rule covers any class Jackson builds through a constructor: Supplier came back from POST /lab/suppliers as {"name":"Keychron","country":"CN"}, not with country first.

Jackson could build Supplier, which has no no-arg constructor and no annotation, because Jackson 3 enables MapperFeature.DETECT_PARAMETER_NAMES and Spring Boot's build setup compiles with -parameters: the compiled Supplier.class carries a MethodParameters attribute. use-jackson2-defaults=true turns the detection off, and the same POST failed with a 500 whose root cause was:

Text
tools.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.example.demo.lab.JsonLabController$Supplier` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)

Serializing Java objects to JSON with Jackson

Records need no annotations

Added to JsonLabController:

Java
public record ProductSummary(Long id, String name, BigDecimal price, Instant listedAt) {}
 
@GetMapping("/summary")
public ProductSummary summary() {
    return new ProductSummary(1L, "Mechanical keyboard", new BigDecimal("1290000"), LISTED);
}
Bash
curl -s http://localhost:8118/lab/summary
Text
{"id":1,"name":"Mechanical keyboard","price":1290000,"listedAt":"2026-10-17T02:30:00Z"}

No getters, no annotations, no default constructor. Jackson reads a record through its accessor methods — name(), not getName() — and writes the components in declaration order. That makes records the natural type for anything that exists only to become JSON, which is where this article ends up.

Renaming a property with @JsonProperty

When the JSON name has to differ from the Java name, for instance because an existing mobile client reads product_name, @JsonProperty on the component sets it:

Java
public record MobileProduct(Long id, @JsonProperty("product_name") String name, BigDecimal price) {}
 
@GetMapping("/mobile")
public MobileProduct mobile() {
    return new MobileProduct(1L, "Mechanical keyboard", new BigDecimal("1290000"));
}
 
@PostMapping("/mobile")
public MobileProduct mobileIn(@RequestBody MobileProduct product) {
    log.info("Deserialized {}", product);
    return product;
}
Bash
curl -s http://localhost:8118/lab/mobile
Text
{"id":1,"product_name":"Mechanical keyboard","price":1290000}

The rename works in both directions. Posting {"id":1,"name":"Mechanical keyboard","price":1290000}, with the Java name, logged:

Text
2026-09-12T14:03:51.110+07:00  INFO 36561 --- [demo] [nio-8118-exec-1] com.example.demo.lab.JsonLabController   : Deserialized MobileProduct[id=1, name=null, price=1290000]

name is now an unknown property, ignored without a word, so the record received null.

Hiding fields with @JsonIgnore

Back to the leak from the first section. The quickest way to keep costPrice and internalNotes out of the response is @JsonIgnore on those two fields of Product:

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.time.Instant;
 
import com.fasterxml.jackson.annotation.JsonIgnore; 
 
public class Product {
 
    private Long id;
    private String name;
    private BigDecimal price;
    @JsonIgnore
    private BigDecimal costPrice;
    private Instant createdAt;
    @JsonIgnore
    private String internalNotes;
 
    // getters and setters unchanged
}
Bash
curl -s http://localhost:8118/api/products/1
Text
{"createdAt":"2026-09-12T07:07:46Z","id":1,"name":"Mechanical keyboard","price":1290000}

The annotation sits on the field, and Jackson applies it to the whole property, getter and setter included, so it also works on input. A lab class with the same @JsonIgnore field and a setter logged IgnoreProbe[name=Webcam, internalNotes=null] for a POST whose body contained "internalNotes":"from the client". For this one response the leak is closed; the section on DTOs shows what stays open.

Leaving out null values with @JsonInclude

A null is written as null by default. A product that is not listed yet:

Java
@GetMapping("/summary/draft")
public ProductSummary draft() {
    return new ProductSummary(3L, "USB-C hub", new BigDecimal("650000"), null);
}
Text
{"id":3,"name":"USB-C hub","price":650000,"listedAt":null}

@JsonInclude(JsonInclude.Include.NON_NULL) on a type leaves out every property whose value is null:

Java
@JsonInclude(JsonInclude.Include.NON_NULL)
public record DraftProduct(Long id, String name, BigDecimal price, Instant listedAt) {}
 
@GetMapping("/draft")
public DraftProduct draftProduct() {
    return new DraftProduct(3L, "USB-C hub", new BigDecimal("650000"), null);
}
Text
{"id":3,"name":"USB-C hub","price":650000}

The same rule for every type in the application is a property:

application.properties
spring.jackson.default-property-inclusion=non_null

With that property and no annotation, /lab/summary/draft also returned {"id":3,"name":"USB-C hub","price":650000}.

Formatting dates with @JsonFormat

Without any annotation, dates are already ISO-8601 strings. @JsonFormat is for a contract that asks for something else:

Java
public record PriceChange(BigDecimal price,
        @JsonFormat(pattern = "dd/MM/yyyy HH:mm", timezone = "Asia/Ho_Chi_Minh") Instant changedAt,
        @JsonFormat(pattern = "dd/MM/yyyy") LocalDate validUntil) {}
 
@GetMapping("/price-change")
public PriceChange priceChange() {
    return new PriceChange(new BigDecimal("1190000"), LISTED, LocalDate.of(2026, 10, 31));
}
 
@PostMapping("/price-change")
public PriceChange priceChangeIn(@RequestBody PriceChange change) {
    log.info("Deserialized {}", change);
    return change;
}
Bash
curl -s http://localhost:8118/lab/price-change
Text
{"price":1190000,"changedAt":"17/10/2026 09:30","validUntil":"31/10/2026"}

Posting that exact body back logged:

Text
2026-09-12T14:03:51.131+07:00  INFO 36561 --- [demo] [nio-8118-exec-7] com.example.demo.lab.JsonLabController   : Deserialized PriceChange[price=1190000, changedAt=2026-10-17T02:30:00Z, validUntil=2026-10-31]

17/10/2026 09:30 was read as 2026-10-17T02:30:00Z, the original instant, because timezone applies to parsing as well. Leaving timezone out on an Instant is a trap:

Java
public record UnzonedPriceChange(BigDecimal price, @JsonFormat(pattern = "dd/MM/yyyy HH:mm") Instant changedAt) {}
 
@GetMapping("/price-change/unzoned")
public UnzonedPriceChange unzoned() {
    return new UnzonedPriceChange(new BigDecimal("1190000"), LISTED);
}
Bash
curl -s -i http://localhost:8118/lab/price-change/unzoned
Text
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:51 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:03:51.139Z","status":500,"error":"Internal Server Error","path":"/lab/price-change/unzoned"}
Text
2026-09-12T14:03:51.139+07:00  WARN 36561 --- [demo] [nio-8118-exec-9] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Unsupported field: DayOfMonth]

An Instant is a point on the timeline with no calendar fields, so there is no day of month to print until a time zone is chosen. The failure happens while writing, which makes it a 500 rather than a 400. LocalDate, as validUntil showed, already has calendar fields and needs no zone.

BigDecimal: plain or scientific notation

Java
public record Prices(BigDecimal listed, BigDecimal stripped, BigDecimal withCents) {}
 
@GetMapping("/prices")
public Prices prices() {
    BigDecimal listed = new BigDecimal("1000");
    return new Prices(listed, listed.stripTrailingZeros(), new BigDecimal("19.90"));
}
Bash
curl -s http://localhost:8118/lab/prices
Text
{"listed":1000,"stripped":1E+3,"withCents":19.90}

stripTrailingZeros() turned 1000 into a BigDecimal with unscaled value 1 and scale -3, and Jackson wrote it in scientific notation, 1E+3. It is valid JSON — JSON.parse in Node.js 22 read it as 1000 — but it is not what anyone expects in a price field, and anything that handles the number as text sees 1E+3. withCents kept its scale: 19.90, not 19.9. One property switches to plain notation:

application.properties
spring.jackson.write.write-bigdecimal-as-plain=true
Text
{"listed":1000,"stripped":1000,"withCents":19.90}

spring.jackson.write.* sets Jackson's StreamWriteFeatures, and WRITE_BIGDECIMAL_AS_PLAIN is one of them.

Enums and Optional fields

Java
public enum ProductStatus { ACTIVE, OUT_OF_STOCK, DISCONTINUED }
 
public record StatusView(Long id, ProductStatus status) {}
 
@GetMapping("/status")
public StatusView status() {
    return new StatusView(2L, ProductStatus.OUT_OF_STOCK);
}
 
@PostMapping("/status")
public StatusView statusIn(@RequestBody StatusView view) {
    log.info("Deserialized {}", view);
    return view;
}

GET /lab/status returned {"id":2,"status":"OUT_OF_STOCK"}. An enum is written as its constant name and read back only from that exact name; posting "status":"out_of_stock" returned 400:

Text
2026-09-12T14:03:51.159+07:00  WARN 36561 --- [demo] [nio-8118-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `com.example.demo.lab.JsonLabController$ProductStatus` from String "out_of_stock": not one of the values accepted for Enum class: [ACTIVE, OUT_OF_STOCK, DISCONTINUED]]

A different JSON value per constant comes from @JsonProperty on the constants:

Java
public enum Availability {
    @JsonProperty("in_stock") IN_STOCK,
    @JsonProperty("out_of_stock") OUT_OF_STOCK
}
 
public record AvailabilityView(Long id, Availability availability) {}

The GET returned {"id":2,"availability":"out_of_stock"}, a POST with "availability":"out_of_stock" was read as OUT_OF_STOCK, and now the constant name is the value that fails: not one of the values accepted for Enum class: [in_stock, out_of_stock]. For output only, spring.jackson.datatype.enum.write-enums-to-lowercase=true turned the status of StatusView into "out_of_stock" without touching the enum.

Optional needs no module either:

Java
public record ProductDetails(String name, Optional<String> description) {}
 
@GetMapping("/details")
public List<ProductDetails> details() {
    return List.of(new ProductDetails("Mechanical keyboard", Optional.of("Hot-swappable switches")),
            new ProductDetails("USB-C hub", Optional.empty()));
}
Text
[{"name":"Mechanical keyboard","description":"Hot-swappable switches"},{"name":"USB-C hub","description":null}]

A present Optional is written as its value and an empty one as null. spring.jackson.default-property-inclusion=non_null left that null in place — the output did not change — while non_absent removed it: [{"name":"Mechanical keyboard","description":"Hot-swappable switches"},{"name":"USB-C hub"}].

Deserializing JSON into Java objects

Records bind through the canonical constructor

NewProduct from the defaults section is a record: no no-arg constructor, no setters, only final fields. Jackson reads the body and calls the canonical constructor with the values:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":650000,"stockQuantity":25}' http://localhost:8118/lab/products
Text
2026-09-12T14:03:51.015+07:00  INFO 36561 --- [demo] [io-8118-exec-10] com.example.demo.lab.JsonLabController   : Deserialized NewProduct[name=USB-C hub, price=650000, stockQuantity=25]

A class works one of two ways. Jackson either creates it with a no-arg constructor and calls setters, as it did for Product, or passes the values to a constructor whose parameter names it can see, as it did for Supplier.

Missing properties, type mismatches and coercion

A missing property of a reference type becomes null, without an error. The body {"name":"USB-C hub","stockQuantity":25} returned 200 and logged:

Text
2026-09-12T14:03:51.048+07:00  INFO 36561 --- [demo] [nio-8118-exec-6] com.example.demo.lab.JsonLabController   : Deserialized NewProduct[name=USB-C hub, price=null, stockQuantity=25]

A missing int is the 400 from the defaults table. A value of the wrong type is a 400 as well:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":"abc","stockQuantity":25}' http://localhost:8118/lab/products
Text
HTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:03:51 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:03:51.070Z","status":400,"error":"Bad Request","path":"/lab/products"}
Text
2026-09-12T14:03:51.070+07:00  WARN 36561 --- [demo] [io-8118-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.math.BigDecimal` from String "abc": not a valid representation]

The request never reached create. Jackson threw while reading, Spring wrapped the exception in the same HttpMessageNotReadableException the previous article met among the @RequestBody failures, and DefaultHandlerExceptionResolver answered 400. Shaping that error body is a later article's subject.

Values that can be converted are converted. "price":"650000", a string, and "stockQuantity":"25" were both accepted, and so was "stockQuantity":2.5:

Text
2026-09-12T14:03:51.089+07:00  INFO 36561 --- [demo] [nio-8118-exec-5] com.example.demo.lab.JsonLabController   : Deserialized NewProduct[name=USB-C hub, price=650000, stockQuantity=2]

The fraction was dropped silently. spring.jackson.deserialization.accept-float-as-int=false makes the same request a 400 with Cannot coerce Floating-point value (2.5) to `int` value (but could if coercion was enabled using `CoercionConfig`).

Accepting other names with @JsonAlias and @JsonCreator

@JsonAlias adds names Jackson accepts when reading, for example when products are imported from feeds that disagree about what to call the name:

Java
public record ImportedProduct(@JsonAlias({"title", "product_name"}) String name, BigDecimal price) {}
 
@PostMapping("/imports")
public ImportedProduct importProduct(@RequestBody ImportedProduct product) {
    log.info("Deserialized {}", product);
    return product;
}

Three POSTs, with the name under title, product_name and name:

Text
2026-09-12T14:03:51.179+07:00  INFO 36561 --- [demo] [nio-8118-exec-8] com.example.demo.lab.JsonLabController   : Deserialized ImportedProduct[name=Webcam 1080p, price=790000]
2026-09-12T14:03:51.186+07:00  INFO 36561 --- [demo] [io-8118-exec-10] com.example.demo.lab.JsonLabController   : Deserialized ImportedProduct[name=Webcam 1080p, price=790000]
2026-09-12T14:03:51.192+07:00  INFO 36561 --- [demo] [nio-8118-exec-2] com.example.demo.lab.JsonLabController   : Deserialized ImportedProduct[name=Webcam 1080p, price=790000]

The aliases affect reading only; each response still said "name".

@JsonCreator tells Jackson which constructor or static factory to use. On a value type whose JSON form is a single string, it lets the class normalise its input:

Java
public static final class Sku {
 
    private final String value;
 
    @JsonCreator
    public Sku(String value) {
        this.value = value.trim().toUpperCase();
    }
 
    @JsonValue
    public String value() {
        return value;
    }
 
    @Override
    public String toString() {
        return "Sku[" + value + "]";
    }
}
 
public record StockLine(Sku sku, int quantity) {}
 
@PostMapping("/stock")
public StockLine stock(@RequestBody StockLine line) {
    log.info("Deserialized {}", line);
    return line;
}
Bash
curl -s -i -H 'Content-Type: application/json' -d '{"sku":" kb-001 ","quantity":5}' http://localhost:8118/lab/stock
Text
2026-09-12T14:03:51.199+07:00  INFO 36561 --- [demo] [nio-8118-exec-4] com.example.demo.lab.JsonLabController   : Deserialized StockLine[sku=Sku[KB-001], quantity=5]

Jackson passed the whole JSON string, " kb-001 ", to the constructor, and @JsonValue wrote the object back as a single string: the response body was {"sku":"KB-001","quantity":5}.

snake_case JSON with spring.jackson.property-naming-strategy

application.properties
spring.jackson.property-naming-strategy=SNAKE_CASE

The value is the name of a constant in Jackson's PropertyNamingStrategies, and it renames in both directions. /lab/summary returned {"id":1,"name":"Mechanical keyboard","price":1290000,"listed_at":"2026-10-17T02:30:00Z"}, and a POST to /lab/products with "stock_quantity":25 was accepted and echoed as {"name":"USB-C hub","price":650000,"stock_quantity":25}. A client that still sends stockQuantity is now sending an unknown property. It is ignored, the int is missing, and that request got a 400.

Customizing the Jackson JsonMapper in Spring Boot

Every change so far was either an annotation on one type or a spring.jackson.* property. Properties and customizations end up in the same place: Boot's JsonMapper.Builder, before the jacksonJsonMapper bean is built from it.

Changing Jackson through spring.jackson properties

The spring.jackson.* properties in the 4.1.1 metadata map onto Jackson's feature enums:

PropertyWhat it setsUsed in this article
spring.jackson.serialization.*SerializationFeature
spring.jackson.deserialization.*DeserializationFeaturefail-on-unknown-properties, accept-float-as-int
spring.jackson.mapper.*MapperFeaturesort-properties-alphabetically
spring.jackson.datatype.datetime.*, .enum.*, .json-node.*DateTimeFeature, EnumFeature, JsonNodeFeaturewrite-enums-to-lowercase
spring.jackson.read.*, spring.jackson.write.*StreamReadFeature, StreamWriteFeaturewrite-bigdecimal-as-plain
spring.jackson.json.read.*, spring.jackson.json.write.*JsonReadFeature, JsonWriteFeature
spring.jackson.default-property-inclusionthe JsonInclude.Include for every propertynon_null, non_absent
spring.jackson.property-naming-strategya PropertyNamingStrategies constant or a class nameSNAKE_CASE
spring.jackson.use-jackson2-defaultsthe defaults Spring Boot used for Jackson 2true

For a stricter API, the most useful one turns unknown properties into an error:

application.properties
spring.jackson.deserialization.fail-on-unknown-properties=true
Bash
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":650000,"stockQuantity":25,"warehouse":"HN-02"}' http://localhost:8118/lab/products
Text
HTTP/1.1 400
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:05:45 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:05:45.648Z","status":400,"error":"Bad Request","path":"/lab/products"}
Text
2026-09-12T14:05:45.648+07:00  WARN 37405 --- [demo] [io-8118-exec-10] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Unrecognized property "warehouse" (class com.example.demo.lab.JsonLabController$NewProduct), not marked as ignorable]

Without it, a misspelt property name in a request disappears without a trace, as warehouse did in the defaults section.

JsonMapperBuilderCustomizer for settings without a property

Some settings have no property. For those, Boot applies every bean of type JsonMapperBuilderCustomizer, from org.springframework.boot.jackson.autoconfigure, to the builder. This one writes every BigDecimal as a JSON string, so a JavaScript client never holds a price as a floating-point number:

src/main/java/com/example/demo/JacksonConfig.java
package com.example.demo;
 
import java.math.BigDecimal;
 
import com.fasterxml.jackson.annotation.JsonFormat;
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class JacksonConfig {
 
    @Bean
    JsonMapperBuilderCustomizer bigDecimalAsString() {
        return builder -> builder.withConfigOverride(BigDecimal.class,
                override -> override.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING)));
    }
}
Bash
curl -s http://localhost:8118/lab/prices
Text
{"listed":"1000","stripped":"1E+3","withCents":"19.90"}

/lab/summary returned {"id":1,"name":"Mechanical keyboard","price":"1290000","listedAt":"2026-10-17T02:30:00Z"}. withConfigOverride changes how one type is handled everywhere, as if every BigDecimal property carried a @JsonFormat with the string shape. Reading is unaffected: a POST that sent "price":650000 as a number logged Deserialized NewProduct[name=USB-C hub, price=650000, stockQuantity=25] and got {"name":"USB-C hub","price":"650000","stockQuantity":25} back.

Defining your own JsonMapper bean

The obvious alternative, a @Bean method that returns a mapper, does something different. Added to the same class:

src/main/java/com/example/demo/JacksonConfig.java
package com.example.demo;
 
import java.math.BigDecimal;
 
import com.fasterxml.jackson.annotation.JsonFormat;
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import tools.jackson.databind.SerializationFeature; 
import tools.jackson.databind.json.JsonMapper; 
 
@Configuration
public class JacksonConfig {
 
    @Bean
    JsonMapperBuilderCustomizer bigDecimalAsString() {
        return builder -> builder.withConfigOverride(BigDecimal.class,
                override -> override.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING)));
    }
 
    @Bean
    JsonMapper jsonMapper() { 
        return JsonMapper.builder() 
                .enable(SerializationFeature.INDENT_OUTPUT) 
                .build(); 
    } 
}

Started with --spring.jackson.property-naming-strategy=SNAKE_CASE, the runner from the first section printed, trimmed to the mapper lines:

Text
mapper bean    : jsonMapper -> tools.jackson.databind.json.JsonMapper
builder bean   : jsonMapperBuilder (prototype: true)
  same mapper as the bean? true
Bash
curl -s http://localhost:8118/lab/summary
Text
{
  "id" : 1,
  "name" : "Mechanical keyboard",
  "price" : 1290000,
  "listedAt" : "2026-10-17T02:30:00Z"
}

The indentation shows the new bean is in use, and jacksonJsonMapper no longer exists: its @Bean method is @ConditionalOnMissingBean, so it backed off, and the converter received jsonMapper instead. Everything Boot would have applied went with it. SNAKE_CASE was ignored (listedAt), and so was the customizer (1290000 is a number again). In JacksonAutoConfiguration, the spring.jackson.* values, the registration of every JacksonModule bean and the ProblemDetail mix-in all reach the mapper through JsonMapperBuilderCustomizers applied to the jsonMapperBuilder bean, and a mapper from JsonMapper.builder() never meets that builder.

When a mapper bean of your own is really needed, build it from Boot's builder:

src/main/java/com/example/demo/JacksonConfig.java
    @Bean
    JsonMapper jsonMapper() { 
        return JsonMapper.builder() 
    JsonMapper jsonMapper(JsonMapper.Builder builder) { 
        return builder 
                .enable(SerializationFeature.INDENT_OUTPUT)
                .build();
    }
Text
{
  "id" : 1,
  "name" : "Mechanical keyboard",
  "price" : "1290000",
  "listed_at" : "2026-10-17T02:30:00Z"
}

The injected JsonMapper.Builder is the prototype bean, already carrying every property and customizer, so the naming strategy, the string prices and the indentation all applied. When all you need is one setting, a property or a customizer is less code than a bean.

Why you should not expose entities in a REST API

Product is not a JPA @Entity — persistence arrives in Chapter 4 — but it plays the role an entity plays: the application's internal model. The controller still hands it to Jackson in both directions, with @JsonIgnore on two fields.

Internal fields leak into responses

The first GET /api/products/1 of this article returned costPrice and internalNotes. Nothing had to go wrong for that to happen; adding a field to the domain class is enough to publish it. @JsonIgnore hid those two fields from every endpoint that returns Product, including any internal endpoint that might legitimately need the cost price.

Mass assignment: the client sets fields it should never set

@JsonIgnore cannot help with fields that must appear in responses but must never be set by a client. A POST that includes them, with the two @JsonIgnore annotations still in place:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"id":1,"name":"Mechanical keyboard","price":1,"costPrice":0,"createdAt":"2020-01-01T00:00:00Z","internalNotes":"hacked"}' http://localhost:8118/api/products
Text
HTTP/1.1 201
Content-Type: application/json
Content-Length: 82
Date: Sat, 12 Sep 2026 07:07:46 GMT
 
{"createdAt":"2020-01-01T00:00:00Z","id":1,"name":"Mechanical keyboard","price":1}
Bash
curl -s http://localhost:8118/api/products/1
Text
{"createdAt":"2020-01-01T00:00:00Z","id":1,"name":"Mechanical keyboard","price":1}

The body carried "id":1. Jackson set it, ProductStore.save saw an id and replaced product 1, and the createdAt from 2020 survived because save only fills it in when it is missing. The keyboard now costs 1. Nothing about the request was malformed, so nothing failed. This is mass assignment: the client wrote fields the API never meant to accept, only because they exist on the object Jackson was filling.

Jackson can be told not to read a property. A lab record with @JsonProperty(access = JsonProperty.Access.READ_ONLY) on its id logged GuardedProduct[id=null, name=Webcam] for a body containing "id":1. But every such rule is one more annotation on the domain class, applied to every endpoint that uses it, and letting two endpoints accept different fields would take @JsonView on top.

The API contract is tied to the internal model

Even without leaks, the JSON of an API built on the domain class is whatever that class looks like today. Renaming createdAt in Java renames the key for every client. Splitting price into an amount and a currency breaks them all at once. Returning an object also returns whatever is reachable from it, which becomes a serious problem once the class is a JPA entity with lazily loaded relations in Chapter 4. A DTO, a Data Transfer Object, is a type that exists only to be the shape of a request or a response, so the internal model and the API contract can change independently.

Request and response DTOs as records

src/main/java/com/example/demo/product/CreateProductRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record CreateProductRequest(String name, BigDecimal price) {}
src/main/java/com/example/demo/product/UpdateProductRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record UpdateProductRequest(String name, BigDecimal price) {}
src/main/java/com/example/demo/product/ProductResponse.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.time.Instant;
 
public record ProductResponse(Long id, String name, BigDecimal price, Instant listedAt) {}
  • CreateProductRequest holds only what a client may send when it creates a product. There is no id, costPrice, createdAt or internalNotes for a request to land in.
  • UpdateProductRequest has the same components today, but it is a separate type: the two records split the single ProductRequest of the previous article, so creating and updating can diverge, for example when renaming becomes allowed and repricing does not.
  • ProductResponse calls its timestamp listedAt, the name the API promises, while the domain keeps createdAt. The mapping has to translate, which is exactly the kind of difference the next sections deal with.

Request DTOs are also where validation constraints go, the subject of the next article. Which layer converts between DTOs and the domain, and which package each type lives in, comes later in this chapter.

Side by side: Product used directly leaks costPrice and internalNotes and lets the client set id and createdAt, while ProductResponse and CreateProductRequest expose only the contract fields and ignore extra keys

Mapping between DTOs and entities manually

The records have to be built from Product, and Product from the records. Plain Java does it, and @JsonIgnore comes off Product again, since no response returns the domain class any more.

A static factory method on the DTO

The shortest version puts each conversion on the record it concerns:

src/main/java/com/example/demo/product/ProductResponse.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.time.Instant;
 
public record ProductResponse(Long id, String name, BigDecimal price, Instant listedAt) {} 
public record ProductResponse(Long id, String name, BigDecimal price, Instant listedAt) { 
 
    public static ProductResponse from(Product product) { 
        return new ProductResponse(product.getId(), product.getName(), product.getPrice(), 
                product.getCreatedAt()); 
    } 
} 
src/main/java/com/example/demo/product/CreateProductRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record CreateProductRequest(String name, BigDecimal price) {} 
public record CreateProductRequest(String name, BigDecimal price) { 
 
    public Product toProduct() { 
        Product product = new Product(); 
        product.setName(name); 
        product.setPrice(price); 
        return product; 
    } 
} 
src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductStore store;
 
    public ProductController(ProductStore store) {
        this.store = store;
    }
 
    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable Long id) {
        Product product = store.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        return ProductResponse.from(product);
    }
 
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ProductResponse create(@RequestBody CreateProductRequest request) {
        return ProductResponse.from(store.save(request.toProduct()));
    }
}
Bash
curl -s http://localhost:8118/api/products/1
Text
{"id":1,"name":"Mechanical keyboard","price":1290000,"listedAt":"2026-09-12T07:23:42Z"}

After one ordinary POST had created product 3, the mass assignment request again:

Bash
curl -s -i -H 'Content-Type: application/json' -d '{"id":1,"name":"USB-C hub","price":1,"costPrice":0,"createdAt":"2020-01-01T00:00:00Z","internalNotes":"hacked"}' http://localhost:8118/api/products
Text
HTTP/1.1 201
Content-Type: application/json
Content-Length: 71
Date: Sat, 12 Sep 2026 07:23:42 GMT
 
{"id":4,"name":"USB-C hub","price":1,"listedAt":"2026-09-12T07:23:42Z"}

id, costPrice, createdAt and internalNotes had nowhere to go. The request created product 4 with an id and a timestamp from the server, and GET /api/products/1 still returned {"id":1,"name":"Mechanical keyboard","price":1290000,"listedAt":"2026-09-12T07:23:42Z"}. The price of 1 was accepted; rejecting it is validation's job.

The extra keys were ignored because Jackson ignores unknown properties by default. With spring.jackson.deserialization.fail-on-unknown-properties=true, a POST of {"id":1,"name":"USB-C hub","price":650000} was rejected with a 400 instead:

Text
2026-09-12T14:29:27.417+07:00  WARN 49448 --- [demo] [nio-8118-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Unrecognized property "id" (class com.example.demo.product.CreateProductRequest), not marked as ignorable]

Which of the two an API wants is a decision about its contract. With DTOs both are safe, because there is no field for id to land in.

The static factory needs no extra class, and each conversion sits next to the type it produces. The cost is that CreateProductRequest now depends on Product and its setters, and the mapping code for one domain class is spread over several records.

A dedicated mapper class

The alternative keeps the records as plain data, back to the one-line declarations of the previous section, and moves every conversion into one Spring bean:

src/main/java/com/example/demo/product/ProductMapper.java
package com.example.demo.product;
 
import org.springframework.stereotype.Component;
 
@Component
public class ProductMapper {
 
    public Product toEntity(CreateProductRequest request) {
        Product product = new Product();
        product.setName(request.name());
        product.setPrice(request.price());
        return product;
    }
 
    public ProductResponse toResponse(Product product) {
        return new ProductResponse(product.getId(), product.getName(), product.getPrice(),
                product.getCreatedAt());
    }
 
    public void update(UpdateProductRequest request, Product product) {
        product.setName(request.name());
        product.setPrice(request.price());
    }
}
src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductStore store;
    private final ProductMapper mapper;
 
    public ProductController(ProductStore store, ProductMapper mapper) {
        this.store = store;
        this.mapper = mapper;
    }
 
    @GetMapping("/{id}")
    public ProductResponse findById(@PathVariable Long id) {
        return mapper.toResponse(find(id));
    }
 
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ProductResponse create(@RequestBody CreateProductRequest request) {
        Product product = mapper.toEntity(request);
        return mapper.toResponse(store.save(product));
    }
 
    @PutMapping("/{id}")
    public ProductResponse update(@PathVariable Long id, @RequestBody UpdateProductRequest request) {
        Product product = find(id);
        mapper.update(request, product);
        return mapper.toResponse(store.save(product));
    }
 
    private Product find(Long id) {
        return store.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
}
Bash
curl -s -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard (TKL)","price":1190000,"costPrice":0}' http://localhost:8118/api/products/1
Text
HTTP/1.1 200
Content-Type: application/json
Content-Length: 93
Date: Sat, 12 Sep 2026 07:23:44 GMT
 
{"id":1,"name":"Mechanical keyboard (TKL)","price":1190000,"listedAt":"2026-09-12T07:23:43Z"}

update changes only what UpdateProductRequest carries, and costPrice in the body was ignored like any unknown property. With logging.level.org.springframework.web=DEBUG, the POST from the first section now logs the DTOs themselves, since records come with a toString():

Text
2026-09-12T14:23:45.413+07:00 DEBUG 42958 --- [demo] [nio-8118-exec-2] o.s.web.servlet.DispatcherServlet        : POST "/api/products", parameters={}
2026-09-12T14:23:45.414+07:00 DEBUG 42958 --- [demo] [nio-8118-exec-2] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.product.ProductController#create(CreateProductRequest)
2026-09-12T14:23:45.439+07:00 DEBUG 42958 --- [demo] [nio-8118-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Read "application/json;charset=UTF-8" to [CreateProductRequest[name=USB-C hub, price=650000]]
2026-09-12T14:23:45.442+07:00 DEBUG 42958 --- [demo] [nio-8118-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Using 'application/json', given [*/*] and supported [application/json, application/*+json]
2026-09-12T14:23:45.444+07:00 DEBUG 42958 --- [demo] [nio-8118-exec-2] m.m.a.RequestResponseBodyMethodProcessor : Writing [ProductResponse[id=3, name=USB-C hub, price=650000, listedAt=2026-09-12T07:23:45Z]]
2026-09-12T14:23:45.446+07:00 DEBUG 42958 --- [demo] [nio-8118-exec-2] o.s.web.servlet.DispatcherServlet        : Completed 201 CREATED

A mapper class gives each domain class one place for its conversions, keeps the DTOs free of domain types, and can be tested on its own. Every assignment is still written by hand, though. Add a component to ProductResponse and toResponse stops compiling, which is helpful; add a field with a setter to Product and nothing reminds you to copy it.

MapStruct in Spring Boot

MapStruct is an annotation processor. You declare the mapping methods in an interface, and while the code compiles it generates a class that implements them with ordinary getter, setter and constructor calls. The latest stable release on Maven Central is 1.6.3; 1.7.0 exists only as a beta.

Adding MapStruct 1.6.3 with Gradle or Maven

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	implementation 'org.mapstruct:mapstruct:1.6.3'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Two artifacts with two roles. mapstruct holds the annotations, @Mapper and @Mapping, that your code compiles against. mapstruct-processor runs only inside the compiler, so Gradle declares it in the annotationProcessor configuration and Maven on the compiler plugin's annotationProcessorPaths, not as a dependency. MapStruct is not in Spring Boot's BOM, so both versions are written out. After a Gradle build the application jar contains only the annotations:

Bash
unzip -l build/libs/demo-0.0.1-SNAPSHOT.jar | grep -i mapstruct
Text
    34069  02-01-1980 00:00   BOOT-INF/lib/mapstruct-1.6.3.jar

The Maven form was checked on a project generated with type=maven-project:

Bash
./mvnw clean compile
Text
[INFO] --- compiler:3.15.0:compile (default-compile) @ demo ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 10 source files with javac [debug parameters release 21] to target/classes
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

It wrote ProductMapperImpl.java to target/generated-sources/annotations, identical to the Gradle output apart from the @Generated metadata. parameters in the javac line is the -parameters flag from Boot's parent POM, still in place after the plugin configuration above.

A @Mapper interface and the unmapped target warning

The ProductMapper class becomes an interface with the same name and the same three methods:

src/main/java/com/example/demo/product/ProductMapper.java
package com.example.demo.product;
 
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
 
@Mapper(componentModel = "spring")
public interface ProductMapper {
 
    Product toEntity(CreateProductRequest request);
 
    ProductResponse toResponse(Product product);
 
    void update(UpdateProductRequest request, @MappingTarget Product product);
}

componentModel = "spring" makes the generated class a Spring bean. ProductController does not change: it still injects ProductMapper and calls the same three methods.

Bash
./gradlew compileJava --console=plain
Text
> Task :compileJava
src/main/java/com/example/demo/product/ProductMapper.java:9: warning: Unmapped target properties: "id, costPrice, createdAt, internalNotes".
    Product toEntity(CreateProductRequest request);
            ^
src/main/java/com/example/demo/product/ProductMapper.java:11: warning: Unmapped target property: "listedAt".
    ProductResponse toResponse(Product product);
                    ^
src/main/java/com/example/demo/product/ProductMapper.java:13: warning: Unmapped target properties: "id, costPrice, createdAt, internalNotes".
    void update(UpdateProductRequest request, @MappingTarget Product product);
         ^
3 warnings
 
BUILD SUCCESSFUL in 1s

(Trimmed: Gradle also printed a link to its problems report.) Maven reports the same three, as [WARNING] lines with a column number:

Text
[WARNING] src/main/java/com/example/demo/product/ProductMapper.java:[9,13] Unmapped target properties: "id, costPrice, createdAt, internalNotes".
[WARNING] src/main/java/com/example/demo/product/ProductMapper.java:[11,21] Unmapped target property: "listedAt".
[WARNING] src/main/java/com/example/demo/product/ProductMapper.java:[13,10] Unmapped target properties: "id, costPrice, createdAt, internalNotes".

MapStruct matched name and price by name and reported every target property it found no source for. In toEntity and update those are exactly the fields a client must not set, so leaving them unmapped is correct. In toResponse the report is a real bug, visible in the generated class:

build/generated/sources/annotationProcessor/java/main/com/example/demo/product/ProductMapperImpl.java
    @Override
    public ProductResponse toResponse(Product product) {
        if ( product == null ) {
            return null;
        }
 
        Long id = null;
        String name = null;
        BigDecimal price = null;
 
        id = product.getId();
        name = product.getName();
        price = product.getPrice();
 
        Instant listedAt = null;
 
        ProductResponse productResponse = new ProductResponse( id, name, price, listedAt );
 
        return productResponse;
    }

⚠️ An unmapped target property is a warning, not an error, by default. This mapper compiles, and the generated toResponse passes null for listedAt on every call. The only trace is one line in a build log that usually scrolls past.

@Mapping: source, target and ignore

src/main/java/com/example/demo/product/ProductMapper.java
package com.example.demo.product;
 
import org.mapstruct.Mapper;
import org.mapstruct.Mapping; 
import org.mapstruct.MappingTarget;
 
@Mapper(componentModel = "spring")
public interface ProductMapper {
 
    @Mapping(target = "id", ignore = true) 
    @Mapping(target = "costPrice", ignore = true) 
    @Mapping(target = "createdAt", ignore = true) 
    @Mapping(target = "internalNotes", ignore = true) 
    Product toEntity(CreateProductRequest request);
 
    @Mapping(target = "listedAt", source = "createdAt") 
    ProductResponse toResponse(Product product);
 
    @Mapping(target = "id", ignore = true) 
    @Mapping(target = "costPrice", ignore = true) 
    @Mapping(target = "createdAt", ignore = true) 
    @Mapping(target = "internalNotes", ignore = true) 
    void update(UpdateProductRequest request, @MappingTarget Product product);
}

@Mapping(target = "listedAt", source = "createdAt") connects the two names. ignore = true records that a target property is left alone on purpose. @Mapping is repeatable, one annotation per property. ./gradlew compileJava --console=plain then compiled with no warnings.

What MapStruct generates

build/generated/sources/annotationProcessor/java/main/com/example/demo/product/ProductMapperImpl.java
package com.example.demo.product;
 
import java.math.BigDecimal;
import java.time.Instant;
import javax.annotation.processing.Generated;
import org.springframework.stereotype.Component;
 
@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2026-09-12T14:26:31+0700",
    comments = "version: 1.6.3, compiler: IncrementalProcessingEnvironment from gradle-java-compiler-worker-9.7.1.jar, environment: Java 21.0.6 (Homebrew)"
)
@Component
public class ProductMapperImpl implements ProductMapper {
 
    @Override
    public Product toEntity(CreateProductRequest request) {
        if ( request == null ) {
            return null;
        }
 
        Product product = new Product();
 
        product.setName( request.name() );
        product.setPrice( request.price() );
 
        return product;
    }
 
    @Override
    public ProductResponse toResponse(Product product) {
        if ( product == null ) {
            return null;
        }
 
        Instant listedAt = null;
        Long id = null;
        String name = null;
        BigDecimal price = null;
 
        listedAt = product.getCreatedAt();
        id = product.getId();
        name = product.getName();
        price = product.getPrice();
 
        ProductResponse productResponse = new ProductResponse( id, name, price, listedAt );
 
        return productResponse;
    }
 
    // update(...) follows, see the last subsection
}
  • It is a plain @Component implementing your interface, so component scanning registers it. A line added to the runner from the first section printed ProductMapper : productMapperImpl -> com.example.demo.product.ProductMapperImpl.
  • ProductResponse is a record, and MapStruct used its canonical constructor. It reads each source value into a local variable, then calls new ProductResponse( id, name, price, listedAt ). Product has setters, so toEntity uses those. Records as sources are read through their accessors, request.name().
  • Nothing is looked up at runtime: no reflection and no property names as strings, the same kind of code as the hand-written ProductMapper.

With the jar rebuilt, the requests from the previous section returned the same results: the mass assignment POST created product 4 with a server-side timestamp, and the PUT renamed product 1 to Mechanical keyboard (TKL).

At compile time the @Mapper interface goes through mapstruct-processor into a generated ProductMapperImpl, with the unmapped target warning and error; at runtime the productMapperImpl bean is injected into ProductController and calls getters and the record constructor

Failing the build with ReportingPolicy.ERROR

A warning that nobody reads protects nothing. unmappedTargetPolicy turns it into an error. To see it work, one ignore is removed at the same time:

src/main/java/com/example/demo/product/ProductMapper.java
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.ReportingPolicy; 
 
@Mapper(componentModel = "spring") 
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR) 
public interface ProductMapper {
 
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "costPrice", ignore = true)
    @Mapping(target = "createdAt", ignore = true)
    @Mapping(target = "internalNotes", ignore = true) 
    Product toEntity(CreateProductRequest request);
 
    // toResponse and update unchanged
}
Bash
./gradlew compileJava --console=plain
Text
> Task :compileJava FAILED
src/main/java/com/example/demo/product/ProductMapper.java:14: error: Unmapped target property: "internalNotes".
    Product toEntity(CreateProductRequest request);
            ^
1 error

The build then ended with BUILD FAILED in 384ms. With the policy set, a new target property that no source provides stops the build until the mapper maps it or ignores it explicitly.

Updating an existing object with @MappingTarget

update returns void and marks its second parameter with @MappingTarget, so MapStruct writes into the Product it receives instead of creating a new one. The generated method:

build/generated/sources/annotationProcessor/java/main/com/example/demo/product/ProductMapperImpl.java
    @Override
    public void update(UpdateProductRequest request, Product product) {
        if ( request == null ) {
            return;
        }
 
        product.setName( request.name() );
        product.setPrice( request.price() );
    }

The four ignore mappings keep id, costPrice, createdAt and internalNotes of the stored product as they were; there is no setter call for them to make. A null in the request is copied like any other value, because setName( request.name() ) runs unconditionally.

Manual mapping vs MapStruct

Manual mappingMapStruct
Code you writeevery assignmentan interface, plus @Mapping where names differ or a target is skipped
A field with the same name on both sidescopied only if you add the linemapped automatically
A field added to the targetrecord: the constructor call stops compiling; class with setters: no remindera warning at compile time, or an error with ReportingPolicy.ERROR
Names that differplain Java@Mapping(target = …, source = …)
Build setupnoneone dependency and one annotation processor
At runtimedirect method callsdirect method calls in generated code, no reflection
Reading what happensyour codegenerated source under build/generated/sources/annotationProcessor
Record targetsa constructor call you writea constructor call MapStruct writes
Good fita few small DTOs, mappings with real logicmany DTOs with similar shapes

FAQ

Does Spring Boot 4 still use ObjectMapper?

The auto-configured bean is a JsonMapper named jacksonJsonMapper, and JsonMapper extends tools.jackson.databind.ObjectMapper, so the context finds that bean for the type tools.jackson.databind.ObjectMapper too. What is gone is Jackson 2's com.fasterxml.jackson.databind.ObjectMapper: importing it fails with package com.fasterxml.jackson.databind does not exist.

Why is my JSON in alphabetical order after upgrading to Spring Boot 4?

Jackson 3 enables MapperFeature.SORT_PROPERTIES_ALPHABETICALLY. Classes with getters and setters are written alphabetically, while records and classes built through a constructor put the constructor's parameters first. spring.jackson.mapper.sort-properties-alphabetically=false restored declaration order for Product on its own.

Why does null for an int field return 400 in Spring Boot 4?

Jackson 3 enables DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, so a null or a missing value for an int fails with Cannot map `null` into type `int`. Use Integer when the value is optional. spring.jackson.deserialization.fail-on-null-for-primitives=false turned both cases into 0, as did spring.jackson.use-jackson2-defaults=true.

How do I reject unknown JSON properties in Spring Boot?

Set spring.jackson.deserialization.fail-on-unknown-properties=true. Jackson 3 ignores unknown properties by default and Boot 4.1.1 leaves it that way. With the property, a body with an extra key returns 400, and the log names the property: Unrecognized property "id" (class com.example.demo.product.CreateProductRequest), not marked as ignorable.

Does MapStruct use reflection at runtime?

No. The processor generates ProductMapperImpl during compilation, and its methods are plain getter, setter and constructor calls. At runtime it is an ordinary Spring bean, and the only MapStruct jar inside the application is mapstruct-1.6.3.jar with the annotations.

Should DTOs be records or classes?

Records fit well. Jackson 3 serializes them without annotations and reads them through the canonical constructor, and MapStruct generates constructor calls for them. For plain request and response shapes, a record is less code than a class and cannot be modified after Jackson creates it.

Conclusion

Spring Boot turns JSON into objects and back through one converter, JacksonJsonHttpMessageConverter, and one bean, jacksonJsonMapper. In Boot 4 that bean is a Jackson 3 JsonMapper: tools.jackson packages with the old annotations, built once from a builder, throwing unchecked exceptions, with java.time support included. Its defaults are where upgrades surprise people. Class properties come out alphabetically, a null or missing int is a 400, trailing tokens are rejected and unknown properties are still ignored — all measured above, and all switchable through spring.jackson.*, a JsonMapperBuilderCustomizer, or a mapper bean built from Boot's own builder. Annotations shape individual types, but they cannot turn a domain class into a safe API: it leaks fields and accepts ones the client must never set. Request and response DTOs as records fix that at the boundary, and the mapping between them and the domain is either a small hand-written class or a MapStruct interface whose unmapped properties you should make a compile error.

The request DTOs now define what a client may send, but not what counts as a valid value: the price of 1 went straight through. The next article adds that with validation — Bean Validation constraints such as @NotNull, @Size and @Email, triggering them with @Valid, and writing a custom validator.

Related Posts

[Spring Boot Basics] API Documentation in Spring Boot with springdoc-openapi and Swagger UI

springdoc-openapi 3.1.1 on Spring Boot 4.1.1, checked on a running jar: the OpenAPI 3.1 document at /v3/api-docs, Swagger UI and Try it out, what springdoc infers from controllers, DTO records and Bean Validation constraints, which @RestControllerAdvice responses it adds, @Tag, @Operation, @ApiResponse, @Parameter and @Schema on records, a global OpenAPI bean and customizer, GroupedOpenApi, springdoc properties and switching the docs off in a prod profile.

[Spring Boot Basics] @ConfigurationProperties in Spring Boot: Type-Safe Configuration with Validation

Type-safe configuration in Spring Boot 4.1.1 with @ConfigurationProperties, checked against real runs: binding to records without @ConstructorBinding, JavaBean binding and @DefaultValue, the three ways to register a properties class, nested objects, lists, maps, enums, Duration and DataSize conversion, relaxed binding and environment variable names, @Validated fail-fast startup errors, the configuration processor metadata, and a side-by-side comparison with @Value.

[Spring Boot Basics] Global Exception Handling in Spring Boot: @RestControllerAdvice, @ExceptionHandler and ProblemDetail

Global exception handling in Spring Boot 4.1.1, verified on a real project: the default /error body and BasicErrorController, spring.web.error.* replacing server.error.*, @ResponseStatus and ResponseStatusException, @ExceptionHandler in a controller and in @RestControllerAdvice, how Spring picks one handler by type distance, controller, @Order and cause, ProblemDetail (RFC 9457) and application/problem+json, ErrorResponseException, spring.mvc.problemdetails.enabled, ResponseEntityExceptionHandler with a 422 field error list, and a catch-all that keeps framework 4xx responses.

[Spring Boot Basics] @Configuration and @Bean in Spring: When to Use a Factory Method Instead of a Stereotype

Why @Component cannot register a class from a third-party jar, how a @Bean factory method on a @Configuration class does it instead, a decision table for choosing between the two, and proxyBeanMethods demonstrated with real identity hash codes on Spring Boot 4.1.1 — full mode returning one shared singleton, lite mode building three separate objects — plus @Import, static @Bean methods, and three failures reproduced with their real error messages.