Backend

99 Ngày Spring — Ngày 17: Mapping DTO với MapStruct

SSite Admin
13 tháng 08, 2026 6 phút đọc 2 lượt xem
99 Ngày Spring — Ngày 17: Mapping DTO với MapStruct

Ngày 13 đã xác lập nguyên tắc: API giao tiếp bằng DTO, không phải entity. Nhưng nguyên tắc đó kéo theo một lớp code chuyển đổi entity ↔ DTO — viết tay thì lặp lại và dễ bỏ sót khi model thay đổi. Hôm nay ta giao việc đó cho MapStruct: thư viện sinh code mapping lúc biên dịch từ các interface khai báo, nhanh như code viết tay và an toàn kiểu như chính compiler.

Sketchnote Ngày 17: mapping DTO với MapStruct — vì sao không expose entity, mapper sinh lúc biên dịch, @Mapping tùy biến, so sánh các lựa chọn

Vì sao không trả thẳng entity

// Trả thẳng entity từ controller — bốn rủi ro tích lũy:@GetMapping("/{id}")public User detail(@PathVariable Long id) {     // User là @Entity
    return userService.get(id);
}

// 1. Lộ field nhạy cảm: password, ghi chú nội bộ... đi thẳng ra JSON// 2. API bị ràng buộc vào schema DB — đổi cột là vỡ hợp đồng với client// 3. Quan hệ hai chiều (User ↔ Order) → vòng lặp vô hạn khi serialize// 4. Lazy loading (Ngày 27) kích hoạt ngoài transaction//    → LazyInitializationException

// DTO (Ngày 13) là hợp đồng riêng của API. Câu hỏi còn lại:// ai viết code chuyển đổi entity ↔ DTO?
  • Bốn rủi ro trên cùng một gốc: entity là mô hình dữ liệu nội bộ, còn response là hợp đồng công khai — hai vai trò có vòng đời và tốc độ thay đổi khác nhau, gộp làm một là tự ràng buộc schema DB vào client.

  • DTO cắt đứt sự ràng buộc đó: entity đổi tự do phía trong, hợp đồng API giữ ổn định phía ngoài — cái giá phải trả là lớp code chuyển đổi, và đó là bài toán của hôm nay.

Map thủ công và chi phí ẩn

// Map thủ công — hoạt động, nhưng là boilerplate tăng theo quy mô:public static PostResponse from(Post post) {
    return new PostResponse(
            post.getId(),
            post.getTitle(),
            post.getSlug(),
            post.getExcerpt(),
            post.getReadingTime()          // 10 field = 10 dòng cơ học
    );
}

// Rủi ro chính: thêm field mới vào Post và PostResponse// nhưng quên bổ sung tại đây — KHÔNG có lỗi biên dịch,// field mới lặng lẽ mang giá trị null trong mọi response.
  • Với 2–3 DTO, map thủ công hoàn toàn chấp nhận được — series này đã dùng PostResponse.from(post) từ Ngày 13 và không có gì sai.

  • Chi phí lộ ra theo quy mô: mỗi entity kéo theo 2–4 DTO (response, create, update, summary), mỗi DTO một method map — hàng trăm dòng gán field không có giá trị nghiệp vụ nhưng vẫn phải review và bảo trì.

  • Rủi ro đáng ngại nhất không phải số dòng mà là im lặng khi bỏ sót: thêm field mới, quên cập nhật một method map — không lỗi biên dịch, chỉ có null lặng lẽ trong response.

MapStruct — mapper sinh lúc biên dịch

// pom.xml / build.gradle: org.mapstruct:mapstruct//                        + annotationProcessor org.mapstruct:mapstruct-processor

@Mapper(componentModel = "spring")     // implementation sinh ra là một bean Springpublic interface PostMapper {

    PostResponse toResponse(Post post);                // khai báo chữ ký — đủ

    List<PostResponse> toResponses(List<Post> posts);  // map danh sách: tự suy ra

    Post toEntity(CreatePostRequest request);
}

// MapStruct sinh code Java thuần LÚC BIÊN DỊCH (annotation processor)://   public class PostMapperImpl implements PostMapper {//       public PostResponse toResponse(Post post) {//           ...gán từng field tường minh — đọc được, debug được//       }//   }

@RestController@RequestMapping("/api/posts")public class PostController {
    private final PostMapper mapper;   // inject như mọi bean khác (Ngày 04)

    @GetMapping("/{id}")
    public PostResponse detail(@PathVariable Long id) {
        return mapper.toResponse(postService.get(id));
    }
}
  • Bạn khai báo interface (đúng khái niệm vừa học bên series Java hôm nay!), MapStruct sinh class cài đặt lúc biên dịch — mở file PostMapperImpl trong target/generated-sources sẽ thấy code gán field tường minh, không có bất kỳ phép màu runtime nào.

  • componentModel = "spring" gắn @Component vào class sinh ra — mapper trở thành bean, inject qua constructor như mọi thành phần khác (Ngày 04).

  • Field trùng tên và tương thích kiểu được map tự động, kể cả các chuyển đổi phổ biến (kiểu số, String ↔ enum, và map theo danh sách như toResponses).

Tùy biến mapping & so sánh các lựa chọn

@Mapper(componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.ERROR)  // field bỏ sót → LỖI BIÊN DỊCH

public interface PostMapper {

    @Mapping(target = "authorName", source = "author.displayName")  // khác tên, lồng nhau
    @Mapping(target = "tagCount", expression = "java(post.getTags().size())")
    PostResponse toResponse(Post post);

    @Mapping(target = "id", ignore = true)             // không cho client ghi đè id
    void updateEntity(UpdatePostRequest req,
                      @MappingTarget Post post);       // cập nhật entity tại chỗ (PUT)
}

// So với các lựa chọn khác://   Map thủ công    — kiểm soát tuyệt đối, nhưng sót field không ai báo//   ModelMapper     — reflection lúc RUNTIME: chậm hơn, lỗi chỉ lộ khi chạy//   MapStruct       — sinh code lúc BIÊN DỊCH: nhanh như code tay,//                     type-safe, sót field (policy ERROR) chặn ngay khi build
  • @Mapping xử lý mọi trường hợp lệch khỏi mặc định: khác tên field, đọc thuộc tính lồng nhau (author.displayName), biểu thức tính toán, hay ignore các field không cho phép ghi.

  • unmappedTargetPolicy = ERROR là cấu hình đáng bật ngay từ đầu: field đích chưa được map trở thành lỗi biên dịch — đúng triết lý "phát hiện lỗi càng sớm càng tốt" của bài Java Ngày 16, áp vào tầng mapping.

  • Mẫu @MappingTarget cập nhật entity tại chỗ phục vụ PUT/PATCH: đọc entity từ DB, áp thay đổi từ request lên chính đối tượng đó thay vì tạo mới — giữ nguyên id và các field không thuộc hợp đồng cập nhật.

Bài tập nhỏ

  • Thêm MapStruct vào project bài tập: khai báo BookMapper với toResponse/toEntity, thay các method map thủ công hiện có và xác nhận API trả kết quả như cũ.

  • Mở class BookMapperImpl được sinh ra trong target/generated-sources — đọc để xác nhận không có reflection, chỉ là các phép gán field.

  • Bật unmappedTargetPolicy = ERROR, thêm một field mới vào BookResponse — quan sát lỗi biên dịch chỉ đích danh field bị bỏ sót, rồi bổ sung @Mapping tương ứng.

  • Viết updateEntity(UpdateBookRequest, @MappingTarget Book) cho endpoint PUT — kiểm chứng id không đổi sau khi cập nhật dù request có gửi kèm id khác.

Kết luận

Ranh giới entity – DTO giờ được duy trì với chi phí gần bằng không: MapStruct sinh code mapping lúc biên dịch từ interface khai báo, @Mapping phủ các trường hợp tùy biến, và unmappedTargetPolicy = ERROR biến rủi ro bỏ sót field thành lỗi build. API của ta đã nhận đúng, trả đúng, báo lỗi đúng và tách bạch mô hình trong – ngoài. Ngày 18 ta xử lý bài toán không thể né khi dữ liệu lớn dần: phân trang và sắp xếp với Pageable — trả dữ liệu theo trang đúng chuẩn thay vì dồn cả bảng vào một response. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 17: Interface

Ngày 17 của 99 Ngày Java: interface — hợp đồng thuần túy tách khỏi cây kế thừa, implement nhiều interface cùng lúc, default & static method cho phép API tiến hóa không phá vỡ mã cũ, và functional interface — cửa ngõ sang lambda và lập trình hàm.

13 thg 8, 20266 phút0
99 Ngày Java — Ngày 16: Abstract class

Ngày 16 của 99 Ngày Java: abstract class và abstract method — chuyển quy ước override thành ràng buộc biên dịch, khi nào nên dùng, mẫu template method (lớp cha kiểm soát trình tự, lớp con cung cấp chi tiết), và bảng so sánh với interface trước khi tìm hiểu nó ở Ngày 17.

12 thg 8, 20266 phút44
99 Ngày Spring — Ngày 16: Xử lý lỗi toàn cục

Ngày 16 của 99 Ngày Spring: tập trung xử lý lỗi về một điểm với @RestControllerAdvice + @ExceptionHandler, chuẩn hóa body lỗi theo ProblemDetail (RFC 7807), kiểm soát response cho lỗi validation của @Valid, và handler dự phòng cho 500 — log chi tiết ở server, trả thông điệp chung cho client.

12 thg 8, 20266 phút18