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.
![]()
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.1 và MapStruct 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ụ.
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:
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ề:
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);
}
}curl -s http://localhost:8118/api/products/1{"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:
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)));
}
}
}
}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? trueJacksonJsonHttpMessageConverterlà 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ó trongspring-web-7.0.9.jarnhưng mang@Deprecated(since = "7.0", forRemoval = true).- Converter không có cấu hình riêng. Boot tạo nó quanh bean
JsonMappertênjacksonJsonMapper, vàsame mapper as the bean? truecho 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. jsonMapperBuilderlà một beanJsonMapper.Builderscope 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:
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":650000}' http://localhost:8118/api/products2026-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 CREATEDRead ... 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.

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:
\--- 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 và @JsonFormat như trước. Một class dùng cả hai sẽ import từ hai gốc:
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:
package com.example.demo;
import com.fasterxml.jackson.databind.ObjectMapper;
class LegacyImport {
ObjectMapper mapper;
}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);
}
}./gradlew -q compileJavaPhần output của compiler, đã bỏ đường dẫn thư mục project:
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 errorsHai 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:
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());
}
}
}{"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 0rebuild() 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
writeValueAsString và readValue 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:
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:
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:
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-jdk8 và jackson-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.javatime và tools.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:
@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ờ:
curl -s http://localhost:8118/lab/times{"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ả Duration và java.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:
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":650000,"stockQuantity":null}' http://localhost:8118/lab/productsHTTP/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:
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:
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_PROPERTIES và DEFAULT_VIEW_INCLUSION. Kết quả của hai lần chạy:
| Hành vi | Mặc định của Spring Boot 4.1.1 | use-jackson2-defaults=true |
|---|---|---|
Instant, LocalDate, LocalDateTime, OffsetDateTime, ZonedDateTime, Duration | chuỗ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 body | bỏ qua, 200 | bỏ qua, 200 |
null cho một int | 400 | nhận thành 0 |
int bị thiếu trong body | 400 | nhận thành 0 |
| Giá trị JSON thứ hai nằm sau giá trị đầu | 400, Trailing token | bỏ qua, 200 |
Ký tự thừa sau giá trị (x) | 400, Unrecognized token 'x' | bỏ qua, 200 |
2.5 cho một int | nhận thành 2 | nhận thành 2 |
| Thứ tự property, class có getter | theo alphabet | theo thứ tự khai báo |
| Thứ tự property, record | theo thứ tự component | theo 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:
{"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à:
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:
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);
}curl -s http://localhost:8118/lab/summary{"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 đó:
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;
}curl -s http://localhost:8118/lab/mobile{"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:
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ữ costPrice và internalNotes khỏi response là đặt @JsonIgnore lên hai field đó của Product:
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
}curl -s http://localhost:8118/api/products/1{"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:
@GetMapping("/summary/draft")
public ProductSummary draft() {
return new ProductSummary(3L, "USB-C hub", new BigDecimal("650000"), null);
}{"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:
@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);
}{"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:
spring.jackson.default-property-inclusion=non_nullVớ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:
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;
}curl -s http://localhost:8118/lab/price-change{"price":1190000,"changedAt":"17/10/2026 09:30","validUntil":"31/10/2026"}POST ngược lại đúng body đó cho ra log:
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:
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);
}curl -s -i http://localhost:8118/lab/price-change/unzonedHTTP/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"}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
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"));
}curl -s http://localhost:8118/lab/prices{"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:
spring.jackson.write.write-bigdecimal-as-plain=true{"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
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:
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:
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:
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()));
}[{"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ị đó:
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":650000,"stockQuantity":25}' http://localhost:8118/lab/products2026-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:
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:
curl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":"abc","stockQuantity":25}' http://localhost:8118/lab/productsHTTP/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"}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:
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:
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_name và name:
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:
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;
}curl -s -i -H 'Content-Type: application/json' -d '{"sku":" kb-001 ","quantity":5}' http://localhost:8118/lab/stock2026-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
spring.jackson.property-naming-strategy=SNAKE_CASEGiá 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:
| Property | Thiết lập gì | Dùng trong bài |
|---|---|---|
spring.jackson.serialization.* | SerializationFeature | — |
spring.jackson.deserialization.* | DeserializationFeature | fail-on-unknown-properties, accept-float-as-int |
spring.jackson.mapper.* | MapperFeature | sort-properties-alphabetically |
spring.jackson.datatype.datetime.*, .enum.*, .json-node.* | DateTimeFeature, EnumFeature, JsonNodeFeature | write-enums-to-lowercase |
spring.jackson.read.*, spring.jackson.write.* | StreamReadFeature, StreamWriteFeature | write-bigdecimal-as-plain |
spring.jackson.json.read.*, spring.jackson.json.write.* | JsonReadFeature, JsonWriteFeature | — |
spring.jackson.default-property-inclusion | JsonInclude.Include cho mọi property | non_null, non_absent |
spring.jackson.property-naming-strategy | một constant của PropertyNamingStrategies hoặc tên class | SNAKE_CASE |
spring.jackson.use-jackson2-defaults | các giá trị mặc định Spring Boot từng dùng cho Jackson 2 | true |
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:
spring.jackson.deserialization.fail-on-unknown-properties=truecurl -s -i -H 'Content-Type: application/json' -d '{"name":"USB-C hub","price":650000,"stockQuantity":25,"warehouse":"HN-02"}' http://localhost:8118/lab/productsHTTP/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"}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:
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)));
}
}curl -s http://localhost:8118/lab/prices{"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:
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:
mapper bean : jsonMapper -> tools.jackson.databind.json.JsonMapper
builder bean : jsonMapperBuilder (prototype: true)
same mapper as the bean? truecurl -s http://localhost:8118/lab/summary{
"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:
@Bean
JsonMapper jsonMapper() {
return JsonMapper.builder()
JsonMapper jsonMapper(JsonMapper.Builder builder) {
return builder
.enable(SerializationFeature.INDENT_OUTPUT)
.build();
}{
"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ề costPrice và internalNotes. 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:
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/productsHTTP/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}curl -s http://localhost:8118/api/products/1{"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
package com.example.demo.product;
import java.math.BigDecimal;
public record CreateProductRequest(String name, BigDecimal price) {}package com.example.demo.product;
import java.math.BigDecimal;
public record UpdateProductRequest(String name, BigDecimal price) {}package com.example.demo.product;
import java.math.BigDecimal;
import java.time.Instant;
public record ProductResponse(Long id, String name, BigDecimal price, Instant listedAt) {}CreateProductRequestchỉ chứa những gì client được gửi khi tạo product. Không cóid,costPrice,createdAthayinternalNotesnào để request rơi vào.UpdateProductRequesthiện có cùng các component, nhưng là một type riêng: hai record này táchProductRequestduy 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á.ProductResponsegọ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.

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:
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());
}
} 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;
}
} 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()));
}
}curl -s http://localhost:8118/api/products/1{"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:
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/productsHTTP/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 và internalNotes 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:
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:
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());
}
}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));
}
}curl -s -i -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard (TKL)","price":1190000,"costPrice":0}' http://localhost:8118/api/products/1HTTP/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():
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 CREATEDClass 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
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'
}<properties>
<java.version>21</java.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- spring-boot-starter-webmvc-test unchanged -->
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>Hai artifact, hai vai trò. mapstruct chứa các annotation, @Mapper và @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:
unzip -l build/libs/demo-0.0.1-SNAPSHOT.jar | grep -i mapstruct 34069 02-01-1980 00:00 BOOT-INF/lib/mapstruct-1.6.3.jarDạng Maven được kiểm tra trên một project sinh với type=maven-project:
./mvnw clean compile[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 SUCCESSNó 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 đó:
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.
./gradlew compileJava --console=plain> 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:
[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 name và price theo tên và báo mọi target property mà nó không tìm được source. Trong toEntity và update, đó 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:
@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ềnnullcholistedAtở 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
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ì
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
@Componentbì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 raProductMapper : productMapperImpl -> com.example.demo.product.ProductMapperImpl. ProductResponselà 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ọinew ProductResponse( id, name, price, listedAt ).Productcó setter, nêntoEntitydù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
ProductMapperviế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à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:
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
}./gradlew compileJava --console=plain> Task :compileJava FAILED
src/main/java/com/example/demo/product/ProductMapper.java:14: error: Unmapped target property: "internalNotes".
Product toEntity(CreateProductRequest request);
^
1 errorBuild 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:
@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, createdAt và internalNotes 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 tay | MapStruct | |
|---|---|---|
| Code phải viết | từng phép gán | mộ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ía | chỉ được copy nếu bạn thêm dòng code | tự động map |
| Field mới thêm vào target | record: lời gọi constructor không compile được; class có setter: không có gì nhắc | cảnh báo lúc compile, hoặc lỗi với ReportingPolicy.ERROR |
| Tên khác nhau | Java thuần | @Mapping(target = …, source = …) |
| Cấu hình build | không có | một dependency và một annotation processor |
| Lúc runtime | gọi method trực tiếp | gọi method trực tiếp trong code được sinh ra, không reflection |
| Đọc xem điều gì xảy ra | code của bạn | source được sinh ra trong build/generated/sources/annotationProcessor |
| Target là record | lời gọi constructor bạn tự viết | lời gọi constructor do MapStruct viết |
| Hợp với | vài DTO nhỏ, mapping có logic thật | nhiề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 và @Email, kích hoạt chúng bằng @Valid, và viết một custom validator.