HttpMessageConverter & Jackson: object thành JSON thế nào

5 câu hỏi~25 phútTừ khóa: HttpMessageConverter, content negotiation, Accept, Content-Type, ObjectMapper, Jackson2ObjectMapperBuilderCustomizer, JavaTimeModule, Instant, WRITE_DATES_AS_TIMESTAMPS, @JsonIgnore, @JsonProperty, DTO, BigDecimal

Tiếp đi: Jackson. Mình chỉ return order mà client nhận JSON. Ai biến nó thành JSON vậy? Với lại ngày tháng trả ra toàn số dài ngoằng.

Câu này gom 3 ý phỏng vấn hay hỏi: HttpMessageConverter, cấu hình Jackson, và "có nên trả Entity ra API không".

1. Bài toán

Endpoint GET /api/orders/42 của shop-app đang trả thẳng Entity:

@GetMapping("/{id}")
public Order get(@PathVariable Long id) {
    return orderRepository.findById(id).orElseThrow();
}

Kết quả gặp ngay:

{"id":42,"createdAt":1760400000.123456789,"total":199.9000000000000056843,
 "customer":{"id":7,"passwordHash":"$2a$10...","orders":[{"id":42,"customer":{...
 → StackOverflowError / "Could not write JSON: failed to lazily initialize..."

2. Vấn đề

3. Giải pháp

4. Code từng bước

Bước 1: dependency. spring-boot-starter-web đã kéo jackson-databind và jackson-datatype-jsr310 (JavaTimeModule). Boot tự đăng ký module này.

Bước 2: config trong application.yml:

spring:
  jackson:
    serialization:
      write-dates-as-timestamps: false      # Instant → "2026-10-14T03:00:00Z"
    deserialization:
      fail-on-unknown-properties: false     # client gửi thêm field không làm vỡ API
    default-property-inclusion: non_null
    time-zone: UTC

Hoặc bằng code, bổ sung vào ObjectMapper của Boot thay vì tạo mới:

@Configuration
class JacksonConfig {
    @Bean
    Jackson2ObjectMapperBuilderCustomizer moneyAsString() {
        return b -> b.featuresToEnable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN)
                     .serializerByType(BigDecimal.class, ToStringSerializer.instance);
    }
}

Bước 3: code Java, DTO cho response:

public record OrderResponse(
        Long id,
        OrderStatus status,
        Instant createdAt,                       // luôn lưu & trả UTC
        BigDecimal total,                        // "199.90", không dùng double
        String currency,
        @JsonProperty("customer_name") String customerName,
        List<OrderLineResponse> lines) {

    public static OrderResponse from(Order o) {
        return new OrderResponse(o.getId(), o.getStatus(), o.getCreatedAt(),
                o.getTotal(), o.getCurrency(), o.getCustomer().getName(),
                o.getLines().stream().map(OrderLineResponse::from).toList());
    }
}

@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) {
    return orderService.get(id);   // service map Entity → DTO trong transaction
}

Bước 4: test bằng curl.

curl -s localhost:8080/api/orders/42
{"id":42,"status":"PAID","createdAt":"2026-10-14T03:00:00Z","total":"199.90",
 "currency":"VND","customer_name":"An","lines":[...]}

curl -i -H 'Accept: application/xml' localhost:8080/api/orders/42
HTTP/1.1 406 Not Acceptable        # không có converter XML

curl -i -X POST localhost:8080/api/orders -H 'Content-Type: text/plain' -d 'hi'
HTTP/1.1 415 Unsupported Media Type

5. Cách hoạt động

Request:  Content-Type: application/json
   │
@RequestBody CreateOrderRequest
   │  duyệt list HttpMessageConverter:
   │    canRead(CreateOrderRequest.class, application/json)?
   │    → MappingJackson2HttpMessageConverter → objectMapper.readValue(...)
   ▼
controller trả OrderResponse
   │  @RestController = @ResponseBody → RequestResponseBodyMethodProcessor
   │  content negotiation:
   │    Accept của client  ∩  media type các converter canWrite(OrderResponse)
   │    → chọn application/json
   ▼
objectMapper.writeValue(response.getOutputStream(), orderResponse)
Response: Content-Type: application/json

Thứ tự converter mặc định: ByteArray, String, Resource, …, Jackson. Vì vậy method trả String thì ra text, không bị bọc dấu nháy JSON.

6. So sánh: trả Entity hay DTO

Trả EntityTrả DTO
Lộ fieldDễ lộ (password, cột nội bộ)Chỉ field khai báo
Quan hệ hai chiềuĐệ quy vô hạn, phải @JsonIgnore/@JsonManagedReferenceKhông có
LAZYLazyInitializationException hoặc N+1 ngầm khi serializeMap trong transaction, kiểm soát query
Đổi schema DBVỡ API contractAPI ổn định
CodeÍt hơnThêm mapper (record + factory, hoặc MapStruct)

7. Khi nào nên dùng / không nên dùng

Mình định tự tạo @Bean ObjectMapper có JavaTimeModule cho chắc. Có vấn đề gì không?

Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: khai báo @Bean ObjectMapper của riêng mình thì Boot lùi lại (@ConditionalOnMissingBean), nên toàn bộ spring.jackson.* trong yml bị bỏ qua, và các module Boot tự đăng ký (ParameterNamesModule, Jdk8Module…) cũng mất nếu bạn quên.

// Nên: chỉnh thêm
@Bean Jackson2ObjectMapperBuilderCustomizer c() { return b -> b.modules(new MyModule()); }

// Hoặc nếu bắt buộc tự tạo, dựng từ builder của Boot
@Bean ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
    return builder.build();
}

Cơ chế "Boot lùi lại khi bạn tự khai báo bean" là auto-configuration, xem bài 4.1.4.

Server mình chạy giờ Việt Nam. Client gửi "2026-10-14T10:00:00" (không có offset) thì lưu sai giờ. Nên làm sao?

Đúng rồi, chuỗi không có offset là mơ hồ: 10h ở đâu? Quy tắc thực tế:

public record CreatePromotionRequest(OffsetDateTime startsAt) {}
// "2026-10-14T10:00:00+07:00" → Instant 2026-10-14T03:00:00Z khi lưu

Nếu dùng LocalDateTime cho thời điểm, kết quả phụ thuộc time zone của JVM: deploy sang container UTC là lệch 7 tiếng.

@JsonIgnore với @JsonProperty(access = WRITE_ONLY) khác gì nhau?

Khác chiều tác dụng:

AnnotationSerialize (trả ra)Deserialize (nhận vào)
@JsonIgnoreBỏBỏ
@JsonProperty(access = WRITE_ONLY)BỏNhận (vd. password khi đăng ký)
@JsonProperty(access = READ_ONLY)TrảBỏ qua (vd. id, createdAt)
@JsonProperty("customer_name")Chỉ đổi tên field JSON

Nhưng nếu tách request DTO và response DTO thì gần như không cần mấy cái access này. Đó là lý do nên tách.

Phỏng vấn hỏi về HttpMessageConverter và Jackson thì trả lời sao?

Q1. Vì sao controller trả object mà client nhận JSON?

🇻🇳 Tiếng Việt:

@RestController có @ResponseBody nên giá trị trả về được ghi thẳng vào body. Spring làm content negotiation: lấy header Accept của client, tìm HttpMessageConverter có thể ghi kiểu đó với media type phù hợp. Với JSON là MappingJackson2HttpMessageConverter, dùng ObjectMapper để serialize. Chiều ngược lại, @RequestBody dùng Content-Type để chọn converter đọc body. Không có converter phù hợp thì trả 406 hoặc 415.

🇬🇧 English:

@RestController includes @ResponseBody, so the return value is written straight to the body. Spring performs content negotiation: it takes the client's Accept header and picks an HttpMessageConverter that can write that type with a compatible media type. For JSON that is MappingJackson2HttpMessageConverter, which uses the ObjectMapper. In the other direction, @RequestBody uses the Content-Type to choose a converter. If none fits, Spring returns 406 or 415.

Q2. Có nên trả Entity JPA trực tiếp ra API không?

🇻🇳 Tiếng Việt:

Không nên. Entity có thể lộ field nhạy cảm, quan hệ hai chiều gây đệ quy vô hạn, quan hệ lazy gây LazyInitializationException hoặc N+1 ngầm khi Jackson gọi getter, và API bị dính chặt với schema DB. Em map sang DTO, thường là record, trong service khi transaction còn mở.

🇬🇧 English:

No. Entities can expose sensitive fields, bidirectional relations cause infinite recursion, lazy relations cause LazyInitializationException or hidden N+1 queries when Jackson calls getters, and the API becomes coupled to the database schema. I map to DTOs, usually records, inside the service while the transaction is still open.

Q3. Xử lý ngày giờ và tiền trong JSON thế nào?

🇻🇳 Tiếng Việt:

Thời điểm dùng Instant hoặc OffsetDateTime, tắt WRITE_DATES_AS_TIMESTAMPS để ra chuỗi ISO-8601 có offset, và chạy server, DB ở UTC. Tiền dùng BigDecimal kèm mã tiền tệ, không dùng double, và có thể serialize thành string để client JavaScript không mất độ chính xác.

🇬🇧 English:

For points in time I use Instant or OffsetDateTime, disable WRITE_DATES_AS_TIMESTAMPS to get ISO-8601 strings with an offset, and run the server and database in UTC. For money I use BigDecimal with a currency code, never double, and I may serialize it as a string so JavaScript clients keep full precision.

Cách nhớ ngắn gọn


Tự kiểm tra

Client gửi Accept: application/xml nhưng app chỉ có Jackson JSON: status gì?

406 Not Acceptable.

Vì sao Instant ra số dài?

WRITE_DATES_AS_TIMESTAMPS đang bật; tắt nó để ra chuỗi ISO-8601.

Tự khai báo @Bean ObjectMapper có tác dụng phụ gì?

Auto-config Jackson của Boot lùi lại, spring.jackson.* bị bỏ qua.

Trả Entity Order có customer LAZY ngoài transaction thì lỗi gì?

LazyInitializationException (hoặc "Could not write JSON") khi Jackson gọi getter.

Field password khi đăng ký: nhận vào nhưng không trả ra, dùng annotation gì?

@JsonProperty(access = WRITE_ONLY), hoặc tốt hơn là tách request/response DTO.