Hình dung hai loại giấy tờ. Entity (JPA) là hồ sơ nội bộ của bạn: đầy tham chiếu chéo sang hồ sơ khác, vài trang ghi chú hậu trường, và có những trang chỉ đọc được khi bạn còn ngồi ở bàn lưu trữ. DTO là tờ tóm tắt bạn soạn riêng cho một vị khách — bạn quyết chính xác cái gì lên đó. Spring Boot cấu hình Jackson — anh thư ký chép giấy tờ — sẵn, và phần lớn mặc định là hợp lý. Bài này về những chỗ cần biết.

JSON ra

record Nguoi(String ten, Integer tuoi,
             @JsonIgnore String bimat,
             @JsonProperty("ngay_sinh") LocalDate ngaySinh,
             Instant taoLuc,
             @JsonInclude(NON_NULL) String ghiChu) {}
  {"ten":"Minh","tuoi":null,"taoLuc":"2026-08-13T10:00:00Z","ngay_sinh":"1995-03-15"}

Bốn điều đọc ra được:

bimat biến mất — @JsonIgnore. Dùng cho mật khẩu, token, mọi thứ không được ra ngoài.

ghiChu biến mất vì null và có @JsonInclude(NON_NULL); còn tuoi vẫn hiện là null vì không có chú thích đó. Muốn bỏ mọi trường null trên toàn ứng dụng:

spring:
  jackson:
    default-property-inclusion: non_null

ngay_sinh đổi tên nhờ @JsonProperty. Đổi cho cả ứng dụng thì dùng spring.jackson.property-naming-strategy: SNAKE_CASE.

java.time hoạt động sẵn. Instant thành 2026-08-13T10:00:00Z, LocalDate thành 1995-03-15 — đúng ISO-8601.

Đây là điểm Spring Boot làm hộ: nó đăng ký JavaTimeModule và tắt WRITE_DATES_AS_TIMESTAMPS. Tự tạo ObjectMapper bằng new thì mất cả hai — như đuổi anh thư ký thuộc lệ nhà rồi thuê một người chẳng biết gì — và bạn nhận về một con số dấu phẩy động, đúng vấn đề ở bài 60 sê-ri Java.

record ánh xạ không cần chú thích. Jackson đọc tên thành phần trực tiếp.

JSON vào

  gửi {"ten":"Lan","ngay_sinh":"2000-01-02","khong_co":123}
  nhận ten=Lan  ngaySinh=2000-01-02  tuoi=null

Hai hành vi im lặng:

Trường lạ bị bỏ qua. Spring Boot tắt FAIL_ON_UNKNOWN_PROPERTIES theo mặc định — khác với Jackson thuần. Với API bên ngoài thì đó là đúng; với DTO nội bộ thì nó che lỗi gõ nhầm.

Bật lại khi cần:

spring:
  jackson:
    deserialization:
      fail-on-unknown-properties: true

Trường thiếu thành zero value. Integer thành null, int thành 0. Không có cảnh báo.

Đây là lý do @Valid ở bài 13 quan trọng: Jackson không kiểm gì cả, validation mới kiểm.

Và như bài 45 sê-ri Go đã nói, dùng kiểu bọc (Integer) thay vì nguyên thuỷ (int) khi cần phân biệt "không gửi" với "gửi số 0".

Vòng lặp tham chiếu

class Cha { public String ten; public Con con; }
class Con { public String ten; public Cha cha; }   // trỏ ngược

Serialize Cha sẽ đệ quy vô hạn và ném StackOverflowError — hoặc tệ hơn, sinh JSON khổng lồ trước khi chết. Đúng cái cảnh chép một hồ sơ mà trang nào cũng trỏ sang trang khác rồi trỏ ngược lại.

Ba cách chữa:

class Con { @JsonBackReference public Cha cha; }
  {"ten":"cha","con":{"ten":"con"}}

Vòng bị cắt. @JsonManagedReference ở phía cha, @JsonBackReference ở phía con.

Cách hai: @JsonIgnore trên trường trỏ ngược — đơn giản hơn nếu bạn không cần chiều đó.

Cách ba, và là cách tôi khuyên: đừng serialize entity. Dùng DTO riêng cho API, và vấn đề biến mất.

Đừng trả entity JPA ra API

Đây là lỗi kiến trúc phổ biến nhất trong dự án Spring — trao thẳng cả tập hồ sơ nội bộ cho khách — và nó gây bốn vấn đề:

Vòng lặp tham chiếu như trên — quan hệ hai chiều là chuẩn trong JPA.

LazyInitializationException khi Jackson chạm vào quan hệ lazy sau khi giao dịch đã đóng — những trang chỉ đọc được khi còn ngồi ở bàn lưu trữ. Bài 28 sẽ nói kỹ.

Truy vấn N+1 ngoài ý muốn — Jackson duyệt quan hệ và mỗi lần duyệt là một truy vấn, một cuộc rượt đuổi qua các hồ sơ trỏ chéo. Bài 27.

Lộ dữ liệu. Thêm một cột vào entity là nó tự động xuất hiện trong API, kể cả khi đó là ghi chú hậu trường không ai nên thấy.

DTO tốn thêm mã, nhưng nó tách hợp đồng API khỏi lược đồ CSDL — và hai thứ đó phải đổi độc lập được.

Tuỳ biến ObjectMapper

Đừng tạo mới, hãy chỉnh cái Spring dựng:

@Bean
Jackson2ObjectMapperBuilderCustomizer tuyBien() {
    return b -> b.serializationInclusion(JsonInclude.Include.NON_NULL)
                 .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}

Cách này giữ mọi cấu hình mặc định của Boot. Khai @Bean ObjectMapper của riêng bạn sẽ thay thế hoàn toàn — và bạn mất JavaTimeModule cùng nhiều thứ khác.

Với kiểu riêng, viết serializer:

class TienSerializer extends JsonSerializer<Tien> {
    public void serialize(Tien t, JsonGenerator g, SerializerProvider p) throws IOException {
        g.writeString(t.dinhDang());
    }
}

rồi gắn bằng @JsonSerialize(using = TienSerializer.class).

Nhúng JSON vào HTML

Nếu bạn render trang có nhúng JSON, nhớ bài 60 sê-ri Java: Jackson không thoát < và >, và một chuỗi chứa </script> sẽ đóng sớm thẻ script.

Với API JSON trả qua HTTP thì không cần lo — trình duyệt không phân tích nó như HTML. Chỉ cần cẩn thận đúng ở chỗ chèn vào trang.

Muốn biết mình có đang lỡ trao hồ sơ nội bộ không, soi danh sách khoá trong ba mươi giây:

curl -s localhost:8080/api/... | jq 'keys'

So danh sách khoá với DTO của bạn. Thấy trường bạn không định lộ ra — password, internalNote, version — nghĩa là bạn đang serialize entity chứ không phải DTO.

Mẫu số chung

Chuyện "đừng trả entity ra API" không phải mẹo riêng của Spring — nó là một quy tắc mà mọi hệ sinh thái đều học: đừng để định dạng truyền-đi của bạn chính là định dạng lưu-trữ của bạn. Hãy chèn một lớp dịch tường minh ở giữa.

  • .NET có DTO và AutoMapper, mẫu "view model"; Django REST Framework bắt khai Serializer riêng chứ không phơi thẳng model; Rails có ActiveModel::Serializer/Jbuilder; GraphQL coi schema là một hợp đồng cố ý tách rời; còn Go thì dùng struct phản hồi riêng với thẻ json.

Lý do sâu: hàn định dạng dây vào định dạng lưu trữ là biến một lần migrate CSDL thành một lần phá vỡ API, và ngược lại — hai thứ phải tiến hoá độc lập thì bị dính làm một. Đây đúng là ranh giới "hợp đồng và hiện thực" mà interface ở bài trước, hay "lớp chống ăn mòn" trong DDD, cùng bảo vệ.

Và hướng an toàn luôn là danh sách cho phép, không phải danh sách cấm: quyết tường minh cái gì được qua ranh giới — cả chiều ra (phơi gì) lẫn chiều vào (nhận gì, chính là phòng lỗ hổng mass assignment mà Rails và GitHub từng dính) — thay vì phơi tất cả rồi cố nhớ mà giấu đi. Sợi chỉ chung: đối tượng bạn lưu và đối tượng bạn gửi đi phục vụ hai ông chủ khác nhau — một bên là CSDL, một bên là client API — nên phải được tự do khác nhau; một lớp dịch tường minh là khoản bảo hiểm rẻ, và "phơi theo danh sách cho phép" là luật cho cả hai chiều.

Ngày mai: Thymeleaf và nội dung tĩnh — khi nào render phía máy chủ vẫn đúng.