Command Palette

Search for a command to run...

[Spring Boot Basics] JSON với Jackson 3 và DTO trong Spring Boot: serialize, deserialize và MapStruct

Một method trong @RestController trả về object Java, còn client nhận được JSON. Một parameter @RequestBody đến dưới dạng JSON, còn method nhận được object Java. Bạn không viết chiều chuyển đổi nào cả: spring-boot-starter-webmvc đã kéo Jackson vào, và Spring Boot đã đăng ký thành phần làm cả hai việc. Trong Spring Boot 4, Jackson ấy là một major version mới, Jackson 3, với tên package mới, một mapper immutable và những giá trị mặc định khác với phiên bản mà phần lớn tutorial Spring Boot đã dựa vào.

Bài này đi qua cả hai nửa của chủ đề. Nửa đầu là chính Jackson: nó nằm ở đâu trong một request, Jackson 3 thay đổi gì với code ứng dụng, Boot 4.1.1 thực sự áp dụng những giá trị mặc định nào, và các annotation định hình JSON theo từng chiều. Nửa sau là câu hỏi mà API nào cũng gặp khi Jackson đã chạy: những object nào nên đi qua ranh giới HTTP. Trả thẳng domain class ra ngoài sẽ làm lộ field và nhận cả những field client không bao giờ được đặt, nên catalogue product chuyển sang DTO cho request và response, map bằng tay trước rồi bằng MapStruct.

JSON được Jackson 3 chuyển thành DTO ở ranh giới, còn entity cùng các field nội bộ nằm phía sau

Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, Jackson 3.1.5, embedded Tomcat 11.0.24), Gradle 9.7.1MapStruct 1.6.3, trên project sinh bởi Spring Initializr với dependencies=web. Application chạy từ file jar với --server.port=8118, các thiết lập spring.jackson.* được truyền dưới dạng argument -- trong cùng command, và mọi JSON body, dòng log và thông báo của compiler đều copy từ chính những lần chạy đó.

Spring Boot chuyển đổi JSON như thế nào: HttpMessageConverter và JsonMapper

Catalogue giữ product trong bộ nhớ cho đến khi database xuất hiện ở Chương 4. Trong bài này Product là domain class: ba field client làm việc cùng, và ba field chỉ thuộc về nghiệp vụ.

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 giữ product trong một ConcurrentHashMap, cấp id từ một AtomicLong, và chỉ điền id với createdAt khi object chưa có sẵn:

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;
    }
}

Phiên bản controller đầu tiên, viết bằng các binding annotation của bài trước, đưa thẳng Product cho client và nhận thẳng nó về:

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}

Hai chi tiết của dòng đó sẽ quay lại ở phần sau: các key được sắp theo alphabet chứ không theo thứ tự khai báo field, và costPrice cùng internalNotes đã đến tay client. Trước hết, hãy xem ai đã viết ra nó.

Jackson message converter và bean JsonMapper

Giá trị trả về của một method trong @RestController trở thành response body thông qua RequestResponseBodyMethodProcessor của Spring MVC. Nó duyệt một danh sách HttpMessageConverter và dùng converter đầu tiên ghi được type của giá trị đó ở một media type mà client chấp nhận; parameter @RequestBody đi qua đúng danh sách ấy theo chiều ngược lại. Runner dưới đây in ra danh sách đó, cùng các bean Jackson trong 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 là JSON converter của Spring Framework 7 dành cho Jackson 3. MappingJackson2HttpMessageConverter, converter Jackson 2 mà Spring Boot 3 từng đăng ký, vẫn có trong spring-web-7.0.9.jar nhưng mang @Deprecated(since = "7.0", forRemoval = true).
  • Converter không có cấu hình riêng. Boot tạo nó quanh bean JsonMapper tên jacksonJsonMapper, và same mapper as the bean? true cho thấy đó chính là instance ấy. Cấu hình gì trên bean này thì áp dụng cho mọi JSON body của request và response.
  • jsonMapperBuilder là một bean JsonMapper.Builder scope prototype: mỗi chỗ inject nhận một builder mới đã mang sẵn cấu hình của Boot. Nó sẽ quan trọng ở phần tùy chỉnh.
  • Năm converter đứng trước Jackson xử lý byte[], String, resource và dữ liệu form. Record hay class thông thường đều rơi xuống Jackson.

Với logging.level.org.springframework.web=DEBUG, một request POST cho thấy cả hai chiều:

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 [...] là converter deserialize body thành Product trước khi method chạy. Using 'application/json' là bước content negotiation chọn type cho response, còn Writing [...] là chính converter đó serialize object được trả về. Product không có toString(), nên log chỉ hiện Product@69867be0; các record ở phần sau của bài sẽ in ra nội dung.

Hình dưới đi theo một request POST qua phiên bản API được xây ở cuối bài, khi DTO đứng ở cả hai phía controller. Converter và bean mapper đóng đúng vai trò như ở trên.

Tám bước đánh số của một request POST: body đi qua JacksonJsonHttpMessageConverter và bean jacksonJsonMapper thành CreateProductRequest, controller map sang Product rồi sang ProductResponse, và chính converter đó ghi JSON response 201

Jackson 3 thay đổi gì trong Spring Boot 4

Spring Boot 4 chuyển từ Jackson 2 sang Jackson 3. ./gradlew dependencies --configuration runtimeClasspath, cắt còn các dòng Jackson:

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 phụ thuộc spring-boot-starter-jackson, thứ kéo theo phần tích hợp Jackson của Boot, spring-boot-jackson, và jackson-databind 3.1.5. Có bốn thay đổi chạm tới code bạn viết.

Package tools.jackson, annotation com.fasterxml.jackson

Jackson 3 chuyển Maven group và các Java package từ com.fasterxml.jackson sang tools.jackson: artifact tools.jackson.core:jackson-databind chứa tools.jackson.databind.json.JsonMapper. Annotation thì vẫn ở chỗ cũ. jackson-annotations vẫn là com.fasterxml.jackson.core:jackson-annotations, version 2.21, và Jackson 3 đọc đúng những @JsonProperty, @JsonIgnore@JsonFormat như trước. Một class dùng cả hai sẽ import từ hai gốc:

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 viết theo cách của Jackson 2 sẽ không compile được. Class đầu tiên bên dưới copy từ một project Spring Boot 3; class thứ hai đã dùng package mới nhưng vẫn cấu hình mapper theo cách Jackson 2 cho phép:

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

Phần output của compiler, đã bỏ đường dẫn thư mục project:

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

Hai lỗi đầu là do package đã chuyển: com.fasterxml.jackson.databind hoàn toàn không có trên classpath. Lỗi thứ ba là thay đổi tiếp theo.

JsonMapper là immutable và được tạo bằng builder

ObjectMapper của Jackson 2 được tạo trước rồi mới cấu hình, với các lời gọi như configure(...), registerModule(...) hay setSerializationInclusion(...). ObjectMapper của Jackson 3 không có method nào trong số đó: cấu hình được chốt lúc build mapper. JsonMapper, subclass dành cho JSON của ObjectMapper và cũng là type của bean do Boot tạo, đến từ JsonMapper.builder(), còn rebuild() trả về một builder đã điền sẵn thiết lập của mapper hiện có. Một chương trình độc lập, compile với ba jar Jackson trong cây dependency ở trên:

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() tạo ra mapper thứ hai có thụt dòng, còn original still compact: true cho thấy mapper đầu tiên không bị đụng tới.

Exception của Jackson 3 là unchecked

writeValueAsStringreadValue trong chương trình trên nằm ngoài mọi khối try, và main không khai báo throws. Ở Jackson 2, cả hai method đều khai báo throws JsonProcessingException, và javap trên jackson-core 2.21.5 cho thấy vì sao điều đó buộc mọi nơi gọi phải có try hoặc throws:

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 {

Ở Jackson 3.1.5, class gốc là 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 {

Vì vậy khối catch (JacksonException e) trong chương trình là tùy chọn. Nó bắt được một InvalidFormatException do ngày sai định dạng, và RuntimeException? true xác nhận type. Dù sao controller cũng hiếm khi tự gọi mapper: một body mà Jackson không đọc được sẽ thành 400 trước khi method chạy, như phần giá trị mặc định bên dưới cho thấy.

java.time chạy được mà không cần jackson-datatype-jsr310

Dòng đầu tiên chương trình in ra chứa một Instant và một LocalDate ở dạng ISO-8601, từ một mapper được build chỉ bằng JsonMapper.builder(). Cùng Instant đó đi qua một ObjectMapper Jackson 2.21.5 thuần:

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 che được lỗi này vì spring-boot-starter-json 3.5.0 phụ thuộc jackson-datatype-jsr310, jackson-datatype-jdk8jackson-module-parameter-names. Ở Jackson 3, phần hỗ trợ đó nằm luôn trong jackson-databind: jar của nó chứa các package tools.jackson.databind.ext.javatimetools.jackson.databind.ext.jdk8, việc nhận diện tên parameter là một MapperFeature, và cây dependency ở trên không có module nào cần thêm.

Các giá trị mặc định của Jackson 3 trong Spring Boot 4.1.1, đo thực tế

Để thấy các giá trị mặc định trong request thật, một JsonLabController dùng tạm dưới /lab trả lại những gì nó đọc được và ghi log object Java mà nó nhận. Các phần dùng trong mục này, đã lược bỏ import:

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
}

Trước hết là ngày giờ:

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"}

Mọi type ngày giờ đều là chuỗi ISO-8601, kể cả Durationjava.util.Date đời cũ; không có gì ở dạng timestamp số. ZonedDateTime giữ offset +07:00 nhưng không giữ zone id.

Một giá trị null cho field 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"}

Log của application cho biết lý do:

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)]

Bỏ hẳn stockQuantity khỏi body cũng cho đúng mã 400 và thông báo đó. Hai giá trị JSON trong cùng một body, {"name":"USB-C hub","price":650000,"stockQuantity":25}{"name":"Webcam"}, cũng bị từ chối:

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`]

Một property không xác định, "warehouse":"HN-02", thì không: request trả về 200 và key đó đơn giản là biến mất.

Tiếp theo là cùng file jar, cùng các request, nhưng khởi động với --spring.jackson.use-jackson2-defaults=true. Metadata của Boot mô tả property này là "Whether to configure Jackson 3 with the same defaults as Spring Boot previously used for Jackson 2." Trong JacksonAutoConfiguration, nó gọi configureForJackson2() của chính Jackson trên builder, rồi tắt WRITE_DATES_AS_TIMESTAMPS, WRITE_DURATIONS_AS_TIMESTAMPS, FAIL_ON_UNKNOWN_PROPERTIESDEFAULT_VIEW_INCLUSION. Kết quả của hai lần chạy:

Hành viMặc định của Spring Boot 4.1.1use-jackson2-defaults=true
Instant, LocalDate, LocalDateTime, OffsetDateTime, ZonedDateTime, Durationchuỗi ISO-8601, như trênđúng các chuỗi đó
java.util.Date"2026-10-17T02:30:00.000Z""2026-10-17T02:30:00.000+00:00"
Property không xác định trong bodybỏ qua, 200bỏ qua, 200
null cho một int400nhận thành 0
int bị thiếu trong body400nhận thành 0
Giá trị JSON thứ hai nằm sau giá trị đầu400, Trailing tokenbỏ qua, 200
Ký tự thừa sau giá trị (x)400, Unrecognized token 'x'bỏ qua, 200
2.5 cho một intnhận thành 2nhận thành 2
Thứ tự property, class có gettertheo alphabettheo thứ tự khai báo
Thứ tự property, recordtheo thứ tự componenttheo thứ tự component
Class có constructor duy nhất nhận mọi propertyđọc qua constructor đó500, no Creators, like default constructor, exist

Với một đợt migration không thể làm xong trong một bước, BOM 4.1.1 còn quản lý org.springframework.boot:spring-boot-jackson2, một module đăng ký Jackson2AutoConfiguration, và spring.http.converters.preferred-json-mapper chấp nhận giá trị jackson2, giá trị mà metadata 4.1.1 đánh dấu là deprecated. Series này dùng Jackson 3.

Vì sao thứ tự property thay đổi

Với mặc định của Boot, lần GET /api/products/1 đầu tiên trong bài liệt kê key theo alphabet. Cùng request đó với 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 bật MapperFeature.SORT_PROPERTIES_ALPHABETICALLY. Nếu thứ tự khai báo là hành vi Jackson 2 duy nhất bạn muốn lấy lại thì một property là đủ: khởi động với --spring.jackson.mapper.sort-properties-alphabetically=false và không gì khác, application trả về {"id":1,"name":"Mechanical keyboard","price":1290000,"costPrice":870000,"createdAt":"2026-09-12T07:36:45Z","internalNotes":"Supplier contract ends in December"}.

Record giữ thứ tự component trong cả hai trường hợp. Một feature thứ hai, SORT_CREATOR_PROPERTIES_FIRST, ghi trước những property được truyền vào constructor, theo thứ tự parameter, mà canonical constructor của record nhận mọi component. Quy tắc này áp dụng cho mọi class mà Jackson tạo qua constructor: Supplier quay về từ POST /lab/suppliers dưới dạng {"name":"Keychron","country":"CN"}, chứ không đưa country lên trước.

Jackson tạo được Supplier, một class không có no-arg constructor và không có annotation nào, vì Jackson 3 bật MapperFeature.DETECT_PARAMETER_NAMES và cấu hình build của Spring Boot compile với -parameters: file Supplier.class sau khi compile có attribute MethodParameters. use-jackson2-defaults=true tắt việc nhận diện này, và cùng request POST đó thất bại với mã 500, nguyên nhân gốc là:

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)

Serialize object Java thành JSON với Jackson

Record không cần annotation

Thêm vào 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"}

Không getter, không annotation, không constructor mặc định. Jackson đọc record qua các accessor method — name() chứ không phải getName() — và ghi các component theo thứ tự khai báo. Điều đó khiến record trở thành type tự nhiên cho những thứ chỉ tồn tại để biến thành JSON, cũng là nơi bài này sẽ đi tới.

Đổi tên property với @JsonProperty

Khi tên trong JSON phải khác tên trong Java, chẳng hạn vì một client mobile có sẵn đang đọc product_name, @JsonProperty trên component sẽ đặt tên đó:

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}

Việc đổi tên có hiệu lực ở cả hai chiều. POST body {"id":1,"name":"Mechanical keyboard","price":1290000}, dùng tên Java, cho ra log:

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 giờ là một property không xác định, bị bỏ qua không một lời báo, nên record nhận null.

Ẩn field với @JsonIgnore

Quay lại chuyện rò rỉ ở phần đầu. Cách nhanh nhất để giữ costPriceinternalNotes khỏi response là đặt @JsonIgnore lên hai field đó của 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}

Annotation nằm trên field, và Jackson áp dụng nó cho cả property, gồm cả getter và setter, nên nó cũng có tác dụng với dữ liệu đi vào. Một class thử nghiệm có cùng field @JsonIgnore kèm setter đã ghi log IgnoreProbe[name=Webcam, internalNotes=null] cho một request POST có body chứa "internalNotes":"from the client". Với response này, lỗ rò đã được bịt; phần về DTO sẽ cho thấy những gì vẫn còn hở.

Bỏ qua giá trị null với @JsonInclude

Mặc định null được ghi thành null. Một product chưa được niêm yết:

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) trên một type sẽ bỏ mọi property có giá trị 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}

Muốn áp dụng cùng quy tắc cho mọi type trong application thì dùng property:

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

Với property này và không có annotation, /lab/summary/draft cũng trả về {"id":3,"name":"USB-C hub","price":650000}.

Định dạng ngày giờ với @JsonFormat

Không cần annotation nào, ngày giờ đã là chuỗi ISO-8601. @JsonFormat dành cho những contract yêu cầu định dạng khác:

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"}

POST ngược lại đúng body đó cho ra log:

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 được đọc thành 2026-10-17T02:30:00Z, đúng thời điểm ban đầu, vì timezone cũng áp dụng khi parse. Bỏ timezone trên một Instant là một cái bẫy:

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]

Instant là một điểm trên trục thời gian, không có calendar field nào, nên chưa có ngày trong tháng để in cho tới khi chọn được time zone. Lỗi xảy ra lúc ghi, nên thành 500 chứ không phải 400. LocalDate, như validUntil đã cho thấy, vốn có sẵn calendar field và không cần time zone.

BigDecimal: dạng thường hay dạng khoa học

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() biến 1000 thành một BigDecimal có unscaled value 1 và scale -3, và Jackson ghi nó ở dạng khoa học, 1E+3. Đây vẫn là JSON hợp lệ — JSON.parse trong Node.js 22 đọc ra 1000 — nhưng không ai mong thấy nó trong field giá, và bất cứ thứ gì xử lý con số như văn bản sẽ thấy 1E+3. withCents giữ nguyên scale: 19.90, không phải 19.9. Một property chuyển sang dạng thường:

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

spring.jackson.write.* đặt các StreamWriteFeature của Jackson, và WRITE_BIGDECIMAL_AS_PLAIN là một trong số đó.

Enum và field Optional

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 trả về {"id":2,"status":"OUT_OF_STOCK"}. Enum được ghi bằng tên constant và chỉ đọc lại được từ đúng tên đó; POST "status":"out_of_stock" trả về 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]]

Muốn mỗi constant có một giá trị JSON khác thì đặt @JsonProperty lên các constant:

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

GET trả về {"id":2,"availability":"out_of_stock"}, POST với "availability":"out_of_stock" được đọc thành OUT_OF_STOCK, và giờ chính tên constant lại là giá trị bị từ chối: not one of the values accepted for Enum class: [in_stock, out_of_stock]. Nếu chỉ cần đổi chiều ghi, spring.jackson.datatype.enum.write-enums-to-lowercase=true biến status của StatusView thành "out_of_stock" mà không phải sửa enum.

Optional cũng không cần module nào:

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}]

Optional có giá trị được ghi thành chính giá trị đó, Optional rỗng thành null. spring.jackson.default-property-inclusion=non_null để nguyên null đó — output không đổi — còn non_absent thì bỏ nó đi: [{"name":"Mechanical keyboard","description":"Hot-swappable switches"},{"name":"USB-C hub"}].

Deserialize JSON thành object Java

Record được bind qua canonical constructor

NewProduct ở phần giá trị mặc định là một record: không có no-arg constructor, không có setter, chỉ có field final. Jackson đọc body rồi gọi canonical constructor với các giá trị đó:

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]

Class thì có hai cách. Jackson hoặc tạo nó bằng no-arg constructor rồi gọi setter, như đã làm với Product, hoặc truyền giá trị vào một constructor mà nó thấy được tên parameter, như đã làm với Supplier.

Property bị thiếu, sai type và coercion

Một property không phải primitive bị thiếu sẽ thành null, không có lỗi nào. Body {"name":"USB-C hub","stockQuantity":25} trả về 200 và ghi log:

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]

Một int bị thiếu là mã 400 trong bảng giá trị mặc định. Giá trị sai type cũng là 400:

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]

Request không bao giờ tới được create. Jackson ném exception lúc đọc, Spring bọc nó trong đúng HttpMessageNotReadableException mà bài trước đã gặp trong các lỗi của @RequestBody, và DefaultHandlerExceptionResolver trả về 400. Định hình body của lỗi đó là chủ đề của một bài sau.

Những giá trị chuyển đổi được thì sẽ được chuyển. "price":"650000", một chuỗi, và "stockQuantity":"25" đều được chấp nhận, và "stockQuantity":2.5 cũng vậy:

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]

Phần thập phân bị bỏ đi mà không báo gì. spring.jackson.deserialization.accept-float-as-int=false biến cùng request đó thành 400 với Cannot coerce Floating-point value (2.5) to `int` value (but could if coercion was enabled using `CoercionConfig`).

Nhận tên khác với @JsonAlias và @JsonCreator

@JsonAlias thêm những tên mà Jackson chấp nhận khi đọc, chẳng hạn khi import product từ các nguồn dữ liệu đặt tên khác nhau:

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;
}

Ba request POST, với tên nằm dưới title, product_namename:

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]

Alias chỉ ảnh hưởng tới chiều đọc; mọi response vẫn dùng "name".

@JsonCreator chỉ cho Jackson biết constructor hay static factory nào cần dùng. Với một value type có dạng JSON là một chuỗi đơn, nó cho phép class tự chuẩn hóa dữ liệu đầu vào:

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 truyền nguyên chuỗi JSON, " kb-001 ", vào constructor, còn @JsonValue ghi object ngược lại thành một chuỗi đơn: response body là {"sku":"KB-001","quantity":5}.

JSON snake_case với spring.jackson.property-naming-strategy

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

Giá trị là tên của một constant trong PropertyNamingStrategies của Jackson, và nó đổi tên theo cả hai chiều. /lab/summary trả về {"id":1,"name":"Mechanical keyboard","price":1290000,"listed_at":"2026-10-17T02:30:00Z"}, và một POST tới /lab/products với "stock_quantity":25 được chấp nhận và trả lại {"name":"USB-C hub","price":650000,"stock_quantity":25}. Client nào vẫn gửi stockQuantity giờ là đang gửi một property không xác định. Nó bị bỏ qua, int bị thiếu, và request đó nhận 400.

Tùy chỉnh JsonMapper của Jackson trong Spring Boot

Mọi thay đổi từ đầu tới giờ hoặc là annotation trên một type, hoặc là một property spring.jackson.*. Property và các tùy chỉnh đều đổ về cùng một chỗ: JsonMapper.Builder của Boot, trước khi bean jacksonJsonMapper được build từ nó.

Thay đổi Jackson qua các property spring.jackson

Các property spring.jackson.* trong metadata 4.1.1 tương ứng với các enum feature của Jackson:

PropertyThiết lập gìDùng trong bài
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-inclusionJsonInclude.Include cho mọi propertynon_null, non_absent
spring.jackson.property-naming-strategymột constant của PropertyNamingStrategies hoặc tên classSNAKE_CASE
spring.jackson.use-jackson2-defaultscác giá trị mặc định Spring Boot từng dùng cho Jackson 2true

Với một API chặt chẽ hơn, property hữu ích nhất biến property không xác định thành lỗi:

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]

Không có nó, một tên property gõ sai trong request sẽ biến mất không để lại dấu vết, như warehouse ở phần giá trị mặc định.

JsonMapperBuilderCustomizer cho các thiết lập không có property

Một số thiết lập không có property. Với chúng, Boot áp dụng mọi bean có type JsonMapperBuilderCustomizer, từ package org.springframework.boot.jackson.autoconfigure, lên builder. Bean dưới đây ghi mọi BigDecimal thành chuỗi JSON, để client JavaScript không bao giờ giữ giá dưới dạng số floating-point:

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 trả về {"id":1,"name":"Mechanical keyboard","price":"1290000","listedAt":"2026-10-17T02:30:00Z"}. withConfigOverride đổi cách xử lý một type ở mọi nơi, như thể mọi property BigDecimal đều mang @JsonFormat với shape chuỗi. Chiều đọc không bị ảnh hưởng: một POST gửi "price":650000 dạng số đã ghi log Deserialized NewProduct[name=USB-C hub, price=650000, stockQuantity=25] và nhận lại {"name":"USB-C hub","price":"650000","stockQuantity":25}.

Tự định nghĩa bean JsonMapper

Cách thay thế hiển nhiên, một method @Bean trả về mapper, lại làm một việc khác. Thêm vào cùng 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(); 
    } 
}

Khởi động với --spring.jackson.property-naming-strategy=SNAKE_CASE, runner ở phần đầu in ra, cắt còn các dòng về mapper:

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"
}

Phần thụt dòng cho thấy bean mới đang được dùng, còn jacksonJsonMapper không còn tồn tại: method @Bean của nó có @ConditionalOnMissingBean, nên nó lùi lại, và converter nhận jsonMapper thay thế. Mọi thứ Boot lẽ ra áp dụng cũng biến mất theo. SNAKE_CASE bị bỏ qua (listedAt), customizer cũng vậy (1290000 lại là số). Trong JacksonAutoConfiguration, các giá trị spring.jackson.*, việc đăng ký mọi bean JacksonModule và mix-in cho ProblemDetail đều tới mapper thông qua các JsonMapperBuilderCustomizer áp lên bean jsonMapperBuilder, và một mapper tạo từ JsonMapper.builder() không bao giờ đi qua builder đó.

Khi thật sự cần một bean mapper riêng, hãy build nó từ builder của Boot:

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"
}

JsonMapper.Builder được inject là bean prototype, đã mang sẵn mọi property và customizer, nên naming strategy, giá dạng chuỗi và thụt dòng đều được áp dụng. Khi chỉ cần một thiết lập, một property hay một customizer vẫn ít code hơn một bean.

Vì sao không nên để lộ entity trong REST API

Product không phải @Entity của JPA — persistence đến ở Chương 4 — nhưng nó đóng đúng vai trò của một entity: model nội bộ của application. Controller vẫn đưa nó cho Jackson theo cả hai chiều, với @JsonIgnore trên hai field.

Field nội bộ bị lộ ra response

Lần GET /api/products/1 đầu tiên của bài đã trả về costPriceinternalNotes. Chuyện đó xảy ra mà không cần bất cứ thứ gì hỏng; chỉ cần thêm một field vào domain class là đủ để công bố nó ra ngoài. @JsonIgnore giấu hai field đó khỏi mọi endpoint trả về Product, kể cả một endpoint nội bộ có lý do chính đáng để cần giá vốn.

Mass assignment: client đặt những field không được phép đặt

@JsonIgnore không giúp gì với những field phải xuất hiện trong response nhưng client không bao giờ được đặt. Một POST có chứa chúng, trong khi hai annotation @JsonIgnore vẫn còn nguyên:

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}

Body mang "id":1. Jackson gán nó, ProductStore.save thấy đã có id nên thay thế product 1, và createdAt năm 2020 được giữ lại vì save chỉ điền nó khi còn thiếu. Bàn phím giờ có giá 1. Request không có gì sai định dạng, nên không có gì thất bại. Đó là mass assignment: client ghi vào những field mà API chưa bao giờ định nhận, chỉ vì chúng tồn tại trên object mà Jackson đang điền.

Có thể bảo Jackson không đọc một property. Một record thử nghiệm với @JsonProperty(access = JsonProperty.Access.READ_ONLY) trên id đã ghi log GuardedProduct[id=null, name=Webcam] cho một body chứa "id":1. Nhưng mỗi quy tắc như vậy lại thêm một annotation lên domain class, áp dụng cho mọi endpoint dùng nó, và để hai endpoint nhận hai tập field khác nhau thì còn phải thêm @JsonView.

Contract của API bị trói vào model nội bộ

Kể cả khi không rò rỉ, JSON của một API xây trên domain class chính là class đó trông thế nào ở thời điểm hiện tại. Đổi tên createdAt trong Java là đổi tên key với mọi client. Tách price thành số tiền và đơn vị tiền tệ làm hỏng tất cả cùng lúc. Trả về một object cũng là trả về mọi thứ đi tới được từ nó, điều trở thành vấn đề nghiêm trọng khi class là một JPA entity có quan hệ lazy loading ở Chương 4. DTO, Data Transfer Object, là một type chỉ tồn tại để làm hình dạng của request hoặc response, nhờ vậy model nội bộ và contract của API có thể thay đổi độc lập với nhau.

DTO cho request và response dưới dạng record

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 chỉ chứa những gì client được gửi khi tạo product. Không có id, costPrice, createdAt hay internalNotes nào để request rơi vào.
  • UpdateProductRequest hiện có cùng các component, nhưng là một type riêng: hai record này tách ProductRequest duy nhất của bài trước làm hai, để việc tạo và việc cập nhật có thể tách hướng, chẳng hạn khi được phép đổi tên nhưng không được đổi giá.
  • ProductResponse gọi timestamp là listedAt, cái tên mà API cam kết, còn domain vẫn giữ createdAt. Phần mapping phải dịch giữa hai tên, đúng loại khác biệt mà các phần tiếp theo xử lý.

Request DTO cũng là nơi đặt các constraint của validation, chủ đề của bài tiếp theo. Tầng nào chuyển đổi giữa DTO và domain, và mỗi type nằm trong package nào, sẽ được bàn ở một bài sau trong chương này.

Hai bên cạnh nhau: dùng thẳng Product làm lộ costPrice và internalNotes và cho client đặt id cùng createdAt, còn ProductResponse và CreateProductRequest chỉ để lộ các field trong contract và bỏ qua key thừa

Map giữa DTO và entity bằng tay

Record phải được tạo từ Product, và Product phải được tạo từ record. Java thuần làm được việc này, và @JsonIgnore được gỡ khỏi Product, vì không response nào trả thẳng domain class nữa.

Static factory method trên DTO

Phiên bản ngắn nhất đặt mỗi phép chuyển đổi lên record liên quan:

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"}

Sau khi một POST bình thường đã tạo product 3, gửi lại request mass assignment:

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, createdAtinternalNotes không có chỗ nào để rơi vào. Request tạo ra product 4 với id và timestamp do server cấp, còn GET /api/products/1 vẫn trả về {"id":1,"name":"Mechanical keyboard","price":1290000,"listedAt":"2026-09-12T07:23:42Z"}. Giá 1 vẫn được chấp nhận; từ chối nó là việc của validation.

Các key thừa bị bỏ qua vì mặc định Jackson bỏ qua property không xác định. Với spring.jackson.deserialization.fail-on-unknown-properties=true, một POST {"id":1,"name":"USB-C hub","price":650000} bị từ chối với 400:

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]

API muốn cách nào trong hai cách là quyết định về contract của nó. Với DTO, cả hai đều an toàn, vì không có field nào để id rơi vào.

Static factory không cần thêm class, và mỗi phép chuyển đổi nằm cạnh type mà nó tạo ra. Cái giá là CreateProductRequest giờ phụ thuộc vào Product và các setter của nó, còn code mapping cho một domain class bị rải ra nhiều record.

Một class mapper riêng

Cách còn lại giữ record chỉ là dữ liệu, trở về khai báo một dòng như phần trước, và dồn mọi phép chuyển đổi vào một 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 chỉ đổi những gì UpdateProductRequest mang theo, còn costPrice trong body bị bỏ qua như mọi property không xác định. Với logging.level.org.springframework.web=DEBUG, request POST ở phần đầu giờ ghi log chính các DTO, vì record có sẵn 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

Class mapper cho mỗi domain class một chỗ duy nhất chứa các phép chuyển đổi, giữ DTO không dính tới domain type, và có thể test riêng. Tuy vậy mọi phép gán vẫn phải viết tay. Thêm một component vào ProductResponse thì toResponse không compile được nữa, điều này có ích; thêm một field có setter vào Product thì chẳng có gì nhắc bạn copy nó.

MapStruct trong Spring Boot

MapStruct là một annotation processor. Bạn khai báo các method mapping trong một interface, và trong lúc code được compile, nó sinh ra một class implement các method đó bằng những lời gọi getter, setter và constructor bình thường. Bản stable mới nhất trên Maven Central là 1.6.3; 1.7.0 hiện chỉ có bản beta.

Thêm MapStruct 1.6.3 với Gradle hoặc 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'
}

Hai artifact, hai vai trò. mapstruct chứa các annotation, @Mapper@Mapping, mà code của bạn compile cùng. mapstruct-processor chỉ chạy bên trong compiler, nên Gradle khai báo nó trong configuration annotationProcessor còn Maven đặt nó trong annotationProcessorPaths của compiler plugin, không phải một dependency. MapStruct không có trong BOM của Spring Boot, nên cả hai version đều được ghi rõ. Sau khi build bằng Gradle, file jar của application chỉ chứa phần annotation:

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

Dạng Maven được kiểm tra trên một project sinh với 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

Nó ghi ProductMapperImpl.java vào target/generated-sources/annotations, giống hệt output của Gradle ngoại trừ metadata trong @Generated. parameters trong dòng javac là cờ -parameters đến từ parent POM của Boot, vẫn còn nguyên sau khi cấu hình plugin như trên.

Interface @Mapper và cảnh báo unmapped target

Class ProductMapper trở thành một interface cùng tên với đúng ba method đó:

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" biến class được sinh ra thành một Spring bean. ProductController không đổi: nó vẫn inject ProductMapper và gọi đúng ba method.

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

(Đã cắt bớt: Gradle còn in một link tới problems report.) Maven báo đúng ba cảnh báo đó, dưới dạng dòng [WARNING] kèm số cột:

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 khớp nameprice theo tên và báo mọi target property mà nó không tìm được source. Trong toEntityupdate, đó chính là những field client không được đặt, nên để chúng không được map là đúng. Trong toResponse, cảnh báo là một bug thật, thấy rõ trong class được sinh ra:

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;
    }

⚠️ Mặc định, một target property chưa được map chỉ là cảnh báo, không phải lỗi. Mapper này compile bình thường, và toResponse được sinh ra truyền null cho listedAt ở mọi lần gọi. Dấu vết duy nhất là một dòng trong build log, thứ thường trôi qua mà không ai đọc.

@Mapping: source, target và 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") nối hai cái tên. ignore = true ghi nhận rằng một target property được cố ý để nguyên. @Mapping là repeatable, mỗi property một annotation. Sau đó ./gradlew compileJava --console=plain compile không còn cảnh báo nào.

MapStruct sinh ra code gì

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
}
  • Đó là một @Component bình thường implement interface của bạn, nên component scan đăng ký nó. Một dòng thêm vào runner ở phần đầu đã in ra ProductMapper : productMapperImpl -> com.example.demo.product.ProductMapperImpl.
  • ProductResponse là record, và MapStruct dùng canonical constructor của nó. Nó đọc từng giá trị source vào một local variable, rồi gọi new ProductResponse( id, name, price, listedAt ). Product có setter, nên toEntity dùng setter. Record làm source thì được đọc qua accessor, request.name().
  • Không có gì phải tra cứu lúc runtime: không reflection, không tên property dưới dạng chuỗi, cùng loại code với ProductMapper viết tay.

Sau khi build lại jar, các request ở phần trước cho cùng kết quả: POST mass assignment tạo ra product 4 với timestamp của server, và PUT đổi tên product 1 thành Mechanical keyboard (TKL).

Lúc compile, interface @Mapper đi qua mapstruct-processor thành ProductMapperImpl được sinh ra, kèm cảnh báo và lỗi unmapped target; lúc runtime, bean productMapperImpl được inject vào ProductController và gọi getter cùng constructor của record

Làm build fail với ReportingPolicy.ERROR

Một cảnh báo không ai đọc thì chẳng bảo vệ được gì. unmappedTargetPolicy biến nó thành lỗi. Để thấy nó hoạt động, bỏ đi một ignore cùng lúc:

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

Build sau đó kết thúc với BUILD FAILED in 384ms. Khi đã đặt policy, một target property mới mà không source nào cung cấp sẽ chặn build cho tới khi mapper map nó hoặc ignore nó một cách tường minh.

Cập nhật object có sẵn với @MappingTarget

update trả về void và đánh dấu parameter thứ hai bằng @MappingTarget, nên MapStruct ghi vào chính Product nó nhận thay vì tạo object mới. Method được sinh ra:

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() );
    }

Bốn mapping ignore giữ nguyên id, costPrice, createdAtinternalNotes của product đã lưu; không có lời gọi setter nào cho chúng. Một giá trị null trong request vẫn được copy như mọi giá trị khác, vì setName( request.name() ) luôn chạy.

Map bằng tay hay MapStruct

Map bằng tayMapStruct
Code phải viếttừng phép gánmột interface, cộng @Mapping ở chỗ tên khác nhau hoặc target bị bỏ qua
Field trùng tên ở cả hai phíachỉ được copy nếu bạn thêm dòng codetự động map
Field mới thêm vào targetrecord: lời gọi constructor không compile được; class có setter: không có gì nhắccảnh báo lúc compile, hoặc lỗi với ReportingPolicy.ERROR
Tên khác nhauJava thuần@Mapping(target = …, source = …)
Cấu hình buildkhông cómột dependency và một annotation processor
Lúc runtimegọi method trực tiếpgọi method trực tiếp trong code được sinh ra, không reflection
Đọc xem điều gì xảy racode của bạnsource được sinh ra trong build/generated/sources/annotationProcessor
Target là recordlời gọi constructor bạn tự viếtlời gọi constructor do MapStruct viết
Hợp vớivài DTO nhỏ, mapping có logic thậtnhiều DTO có hình dạng giống nhau

FAQ

Spring Boot 4 còn dùng ObjectMapper không?

Bean được auto-configure là một JsonMapper tên jacksonJsonMapper, và JsonMapper kế thừa tools.jackson.databind.ObjectMapper, nên context cũng tìm thấy bean đó với type tools.jackson.databind.ObjectMapper. Thứ đã biến mất là com.fasterxml.jackson.databind.ObjectMapper của Jackson 2: import nó sẽ lỗi package com.fasterxml.jackson.databind does not exist.

Vì sao JSON bị sắp xếp theo alphabet sau khi nâng cấp lên Spring Boot 4?

Jackson 3 bật MapperFeature.SORT_PROPERTIES_ALPHABETICALLY. Class có getter và setter được ghi theo alphabet, còn record và class được tạo qua constructor thì đưa các parameter của constructor lên trước. Chỉ riêng spring.jackson.mapper.sort-properties-alphabetically=false đã trả lại thứ tự khai báo cho Product.

Vì sao null cho field int trả về 400 trong Spring Boot 4?

Jackson 3 bật DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, nên null hoặc giá trị bị thiếu cho một int sẽ lỗi Cannot map `null` into type `int`. Hãy dùng Integer khi giá trị là tùy chọn. spring.jackson.deserialization.fail-on-null-for-primitives=false biến cả hai trường hợp thành 0, spring.jackson.use-jackson2-defaults=true cũng vậy.

Làm sao để từ chối property JSON không xác định trong Spring Boot?

Đặt spring.jackson.deserialization.fail-on-unknown-properties=true. Jackson 3 mặc định bỏ qua property không xác định và Boot 4.1.1 giữ nguyên như vậy. Có property này, body có key thừa sẽ trả về 400, và log nêu tên property: Unrecognized property "id" (class com.example.demo.product.CreateProductRequest), not marked as ignorable.

MapStruct có dùng reflection lúc runtime không?

Không. Processor sinh ra ProductMapperImpl trong lúc compile, và các method của nó là những lời gọi getter, setter và constructor bình thường. Lúc runtime nó là một Spring bean thông thường, và jar MapStruct duy nhất trong application là mapstruct-1.6.3.jar chứa annotation.

DTO nên là record hay class?

Record rất hợp. Jackson 3 serialize record mà không cần annotation và đọc nó qua canonical constructor, còn MapStruct sinh lời gọi constructor cho nó. Với những hình dạng request và response đơn giản, record ít code hơn class và không thể bị sửa sau khi Jackson tạo ra.

Kết luận

Spring Boot chuyển JSON thành object và ngược lại qua một converter, JacksonJsonHttpMessageConverter, và một bean, jacksonJsonMapper. Ở Boot 4, bean đó là một JsonMapper của Jackson 3: package tools.jackson đi cùng annotation cũ, được build một lần từ builder, ném exception unchecked, và có sẵn hỗ trợ java.time. Các giá trị mặc định của nó là chỗ khiến người nâng cấp bất ngờ. Property của class ra theo alphabet, null hoặc int bị thiếu là 400, trailing token bị từ chối còn property không xác định vẫn bị bỏ qua — tất cả đã đo ở trên, và đều đổi được qua spring.jackson.*, một JsonMapperBuilderCustomizer, hoặc một bean mapper build từ chính builder của Boot. Annotation định hình từng type, nhưng không biến được domain class thành một API an toàn: nó làm lộ field và nhận cả những field client không bao giờ được đặt. DTO dạng record cho request và response giải quyết điều đó ngay tại ranh giới, còn việc map giữa chúng với domain là một class nhỏ viết tay hoặc một interface MapStruct mà bạn nên biến unmapped property thành lỗi compile.

Request DTO giờ xác định client được gửi những gì, nhưng chưa xác định giá trị nào là hợp lệ: giá 1 vẫn đi thẳng qua. Bài tiếp theo bổ sung phần đó bằng validation — các constraint của Bean Validation như @NotNull, @Size@Email, kích hoạt chúng bằng @Valid, và viết một custom validator.

Bài viết liên quan

[Spring Boot Basics] Gọi API bên ngoài với RestClient trong Spring Boot: GET, POST, xử lý lỗi và timeout

Gọi HTTP API bên ngoài từ Spring Boot 4.1.1 bằng RestClient, kiểm chứng với một stub chạy local: RestClient so với RestTemplate, WebClient và @HttpExchange, spring-boot-starter-restclient và RestClient.Builder được auto-configure, GET vào record và list, toEntity, encode query parameter, POST, PUT và DELETE, message thật của HttpClientErrorException, onStatus, defaultStatusHandler và exchange, connect timeout và read timeout mặc định lẫn khi cấu hình bằng spring.http.clients được đo thực tế, interceptor để log, và chuyển lỗi upstream thành 502, 503 và 504.

[Spring Boot Basics] Tài liệu API trong Spring Boot với springdoc-openapi và Swagger UI

springdoc-openapi 3.1.1 trên Spring Boot 4.1.1, kiểm chứng trên jar đang chạy: document OpenAPI 3.1 ở /v3/api-docs, Swagger UI và Try it out, những gì springdoc suy ra từ controller, DTO record và Bean Validation constraint, response nào của @RestControllerAdvice được thêm vào, @Tag, @Operation, @ApiResponse, @Parameter và @Schema trên record, bean OpenAPI và customizer toàn cục, GroupedOpenApi, property của springdoc và tắt tài liệu trong profile prod.

[Spring Boot Basics] Xử lý exception tập trung trong Spring Boot: @RestControllerAdvice, @ExceptionHandler và ProblemDetail

Xử lý exception tập trung trong Spring Boot 4.1.1, kiểm chứng trên project thật: body /error mặc định và BasicErrorController, spring.web.error.* thay cho server.error.*, @ResponseStatus và ResponseStatusException, @ExceptionHandler trong controller và trong @RestControllerAdvice, cách Spring chọn một handler theo khoảng cách type, controller, @Order và cause, ProblemDetail (RFC 9457) với application/problem+json, ErrorResponseException, spring.mvc.problemdetails.enabled, ResponseEntityExceptionHandler trả 422 kèm danh sách lỗi theo field, và một handler catch-all giữ nguyên các response 4xx của framework.

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator 9.1.3, kiểm chứng bằng các lần chạy thật: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.