DTO Pattern, Validation & Xử lý lỗi

Tại sao không nên trả Entity trực tiếp? DTO Pattern

Các vấn đề khi trả Entity trực tiếp

Vấn đề 1: Lộ cấu trúc nội bộ — Client thấy passwordHash, internalNotes, và cấu trúc database.

Vấn đề 2: Đệ quy vô hạn — Quan hệ bidirectional gây vòng lặp: Post → author → posts → …

Vấn đề 3: Liên kết chặt API với Database — Đổi tên cột database thì API response thay đổi.

Vấn đề 4: Hình dạng khác nhau — Input cần authorId, output cần authorName — entity chỉ có một hình dạng.

Giải pháp: DTO Pattern

DTO (Data Transfer Object) là class Java đơn giản được thiết kế chuyên cho truyền dữ liệu:

  • Request DTO — những gì client gửi đến server
  • Response DTO — những gì server gửi lại client
Client                    Controller              Service              Repository
  |                          |                      |                      |
  |--- JSON Request -------->|                      |                      |
  |                          |-- Request DTO ------>|                      |
  |                          |                      |-- Entity ----------->|
  |                          |                      |<-- Entity -----------|
  |                          |<-- Response DTO -----|                      |
  |<--- JSON Response -------|                      |                      |

Entity không bao giờ vượt qua ranh giới controller.


Tạo Request DTO và Response DTO

Request DTO

public class CreatePostRequest {
    private String title;
    private String content;
    private String excerpt;
    private Long authorId;       // Chỉ ID, không phải đối tượng User đầy đủ
    private Long categoryId;
    private List<Long> tagIds;
    // constructor, getter, setter...
}

Response DTO

public class PostResponse {
    private Long id;
    private String title;
    private String slug;
    private String content;
    private String status;
    private Long authorId;
    private String authorName;        // Đã làm phẳng từ entity User
    private String authorUsername;
    private String categoryName;      // Đã làm phẳng từ entity Category
    private List<String> tagNames;    // Chỉ tên, không phải entity Tag đầy đủ
    private int commentCount;         // Trường tính toán
    private LocalDateTime createdAt;
    // getter, setter...
}

So sánh JSON

Không có DTO: passwordHash bị lộ, thiếu authorName, có vòng lặp vô hạn.

Có DTO: Đầy đủ, sạch sẽ, không trường nhạy cảm, không tham chiếu vòng.


Ánh xạ thủ công vs MapStruct

Ánh xạ thủ công

@Component
public class PostMapper {

    public PostResponse toResponse(Post post) {
        PostResponse dto = new PostResponse();
        dto.setId(post.getId());
        dto.setTitle(post.getTitle());
        // ... ánh xạ trường trực tiếp

        // Ánh xạ quan hệ — làm phẳng entity liên quan
        if (post.getAuthor() != null) {
            dto.setAuthorId(post.getAuthor().getId());
            dto.setAuthorName(post.getAuthor().getFullName());
        }

        if (post.getTags() != null) {
            dto.setTagNames(post.getTags().stream()
                    .map(Tag::getName).sorted().collect(Collectors.toList()));
        }
        return dto;
    }
}

MapStruct — Sinh code tại thời điểm biên dịch

MapStruct sinh code ánh xạ tự động. Khai báo interface, MapStruct sinh implementation:

@Mapper(componentModel = "spring")
public interface PostMapper {

    @Mapping(source = "author.fullName", target = "authorName")
    @Mapping(source = "category.name", target = "categoryName")
    PostResponse toResponse(Post post);
}

Chọn cách nào?

Tiêu chí Ánh xạ thủ công MapStruct
Đơn giản Dễ hiểu và debug Cần học annotation MapStruct
Boilerplate Lặp lại cho nhiều entity Tối thiểu — chỉ khai báo interface
Hiệu suất Giống MapStruct Sinh tại compile-time, không có overhead runtime

Trong series này, dùng ánh xạ thủ công — đơn giản hơn cho dự án nhỏ. Dự án lớn (20+ entity) nên dùng MapStruct.


Bean Validation với @Valid và Jakarta Validation Annotation

Annotation Validation trên Request DTO

public class CreatePostRequest {

    @NotBlank(message = "Tiêu đề là bắt buộc")
    @Size(min = 3, max = 500, message = "Tiêu đề phải từ 3 đến 500 ký tự")
    private String title;

    @NotBlank(message = "Nội dung là bắt buộc")
    @Size(min = 10, message = "Nội dung phải ít nhất 10 ký tự")
    private String content;

    @NotNull(message = "ID tác giả là bắt buộc")
    @Positive(message = "ID tác giả phải là số dương")
    private Long authorId;

    @Positive(message = "ID danh mục phải là số dương")
    private Long categoryId;   // Tùy chọn — không có @NotNull
}

Bảng Validation Annotation phổ biến

Annotation Áp dụng cho Quy tắc
@NotNull Bất kỳ kiểu Không được null
@NotEmpty String, Collection Không null và không rỗng
@NotBlank String Không null, rỗng, hay chỉ khoảng trắng
@Size(min, max) String, Collection Độ dài/kích thước trong phạm vi
@Email String Phải là định dạng email hợp lệ
@Positive Number Phải > 0
@Pattern(regexp) String Phải khớp biểu thức chính quy

Kích hoạt Validation trong Controller

Thêm @Valid trước tham số @RequestBody:

@PostMapping
public ResponseEntity<PostResponse> createPost(
        @Valid @RequestBody CreatePostRequest request) {
    // Nếu đến được đây, TẤT CẢ validation constraint đã pass.
    // request.getTitle() được đảm bảo non-blank và 3-500 ký tự.
    PostResponse created = postService.createPost(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(created);
}

Không có @Valid, annotation trên DTO không làm gì. @Valid là trigger.


Custom Validator

Tạo Annotation tùy chỉnh

@Constraint(validatedBy = UniqueUsernameValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueUsername {
    String message() default "Tên người dùng đã được sử dụng";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Triển khai Validator

public class UniqueUsernameValidator
        implements ConstraintValidator<UniqueUsername, String> {

    private final UserRepository userRepository;

    // Có thể inject Spring bean vào validator!
    public UniqueUsernameValidator(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public boolean isValid(String username, ConstraintValidatorContext context) {
        if (username == null) return true;  // Để @NotBlank xử lý null
        return !userRepository.existsByUsername(username);
    }
}

Validation xuyên trường (Cross-Field)

Đôi khi validation phụ thuộc nhiều trường (ví dụ “xác nhận mật khẩu phải khớp”):

@Constraint(validatedBy = PasswordMatchValidator.class)
@Target({ElementType.TYPE})  // Áp dụng cho class
public @interface PasswordMatch {
    String message() default "Mật khẩu không khớp";
    // ...
}

Xử lý lỗi toàn cục với @ControllerAdvice

@ControllerAdvice tạo class chặn exception từ bất kỳ controller nào. Thay vì xử lý exception trong mỗi phương thức controller bằng try-catch, bạn xử lý ở một nơi.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException ex) {
        Map<String, Object> error = Map.of(
            "status", 500,
            "error", "Internal Server Error",
            "message", ex.getMessage(),
            "timestamp", LocalDateTime.now().toString()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

Custom Exception Class

Thay vì ném RuntimeException chung chung, tạo exception cụ thể:

// 404 Not Found
public class ResourceNotFoundException extends RuntimeException {
    private final String resourceName;
    private final String fieldName;
    private final Object fieldValue;

    public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
        super(String.format("%s không tìm thấy với %s: %s", resourceName, fieldName, fieldValue));
        this.resourceName = resourceName;
        this.fieldName = fieldName;
        this.fieldValue = fieldValue;
    }
}

// 409 Conflict
public class DuplicateResourceException extends RuntimeException {
    public DuplicateResourceException(String resourceName, String fieldName, Object fieldValue) {
        super(String.format("%s đã tồn tại với %s: %s", resourceName, fieldName, fieldValue));
    }
}

// 400 Bad Request
public class BadRequestException extends RuntimeException {
    public BadRequestException(String message) { super(message); }
}

Sử dụng trong service:

public Post getPostById(Long id) {
    return postRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
}

Xây dựng định dạng Error Response nhất quán

@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
    private int status;
    private String error;
    private String message;
    private String path;
    private LocalDateTime timestamp;
    private Map<String, List<String>> validationErrors;  // Chỉ cho lỗi validation
}

Ví dụ response:

{
  "status": 400,
  "error": "Validation Failed",
  "message": "Một hoặc nhiều trường có lỗi validation",
  "path": "/api/posts",
  "timestamp": "2024-01-15T10:30:00",
  "validationErrors": {
    "title": ["Tiêu đề là bắt buộc"],
    "content": ["Nội dung phải ít nhất 10 ký tự"]
  }
}

Xử lý Exception cụ thể

@ControllerAdvice
public class GlobalExceptionHandler {

    // 404 Not Found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex, HttpServletRequest request) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.NOT_FOUND.value(), "Not Found",
            ex.getMessage(), request.getRequestURI());
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    // 409 Conflict
    @ExceptionHandler(DuplicateResourceException.class)
    public ResponseEntity<ErrorResponse> handleDuplicateResource(
            DuplicateResourceException ex, HttpServletRequest request) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.CONFLICT.value(), "Conflict",
            ex.getMessage(), request.getRequestURI());
        return new ResponseEntity<>(error, HttpStatus.CONFLICT);
    }

    // 400 Validation
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex, HttpServletRequest request) {
        Map<String, List<String>> validationErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError -> {
            validationErrors.computeIfAbsent(fieldError.getField(), key -> new ArrayList<>())
                .add(fieldError.getDefaultMessage());
        });

        ErrorResponse error = new ErrorResponse(
            HttpStatus.BAD_REQUEST.value(), "Validation Failed",
            "Một hoặc nhiều trường có lỗi validation", request.getRequestURI());
        error.setValidationErrors(validationErrors);
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // 400 Type Mismatch (ví dụ: GET /api/posts/abc — "abc" không phải Long)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException ex, HttpServletRequest request) {
        String message = String.format("Tham số '%s' phải thuộc kiểu %s",
            ex.getName(), ex.getRequiredType().getSimpleName());
        ErrorResponse error = new ErrorResponse(
            HttpStatus.BAD_REQUEST.value(), "Bad Request",
            message, request.getRequestURI());
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // 500 Catch-All — lưới an toàn
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex, HttpServletRequest request) {
        System.err.println("Exception chưa xử lý: " + ex.getMessage());
        ErrorResponse error = new ErrorResponse(
            HttpStatus.INTERNAL_SERVER_ERROR.value(), "Internal Server Error",
            "Đã xảy ra lỗi bất ngờ. Vui lòng thử lại sau.",
            request.getRequestURI());
        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

Thực hành: Refactor Blog API với DTO, Validation & Xử lý lỗi

Controller sau refactor

@RestController
@RequestMapping("/api/posts")
public class PostController {

    @PostMapping
    public ResponseEntity<PostResponse> createPost(
            @Valid @RequestBody CreatePostRequest request) {
        // Không try-catch! ResourceNotFoundException được ném bởi service
        // và bắt bởi GlobalExceptionHandler, trả về response 404 đúng cách.
        PostResponse created = postService.createPost(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @GetMapping("/{id}")
    public PostResponse getPost(@PathVariable Long id) {
        return postService.getPostById(id);
    }
}

Controller giờ sạch sẽ: không try-catch, DTO có kiểu thay vì Map, validation tự động với @Valid.

Kiểm thử

# Validation — thiếu trường bắt buộc
curl -X POST http://localhost:8080/api/posts \
  -H "Content-Type: application/json" -d '{}'
# Kết quả 400: validationErrors cho title, content, authorId

# 404 — tài nguyên không tồn tại
curl http://localhost:8080/api/posts/99999
# Kết quả 404: "Post không tìm thấy với id: 99999"

# Type mismatch
curl http://localhost:8080/api/posts/abc
# Kết quả 400: "Tham số 'id' phải thuộc kiểu Long"

Bài tập

  1. Tạo CreateUserRequest DTO với validation: username (3-50 ký tự), email (hợp lệ), password (8+ ký tự). Thêm @UniqueUsername.
  2. Tạo PostSummaryResponse DTO nhẹ cho trang danh sách (không có content).
  3. Xử lý DataIntegrityViolationException trong GlobalExceptionHandler → trả 409 Conflict.
  4. Kiểm thử mọi đường dẫn lỗi: xác nhận mọi response tuân theo cùng format ErrorResponse.

Tổng kết

  • DTO Pattern: Tách Request DTO (client gửi) khỏi Response DTO (server trả). Entity không bao giờ vượt ranh giới controller.
  • Bean Validation: Đánh dấu trường DTO với @NotBlank, @Size, @Email, v.v. Kích hoạt bằng @Valid.
  • Custom Validator: Tạo cặp annotation + validator class cho validation phức tạp.
  • @ControllerAdvice: Xử lý exception tập trung cho toàn bộ ứng dụng.
  • Custom Exception: ResourceNotFoundException (404), DuplicateResourceException (409), BadRequestException (400).
  • Error Response nhất quán: Mọi lỗi tuân theo cùng format — status, error, message, path, timestamp, validationErrors.

Tham chiếu nhanh

Khái niệm Mô tả
DTO Data Transfer Object — tách API contract khỏi domain model
Request DTO Client gửi (chỉ trường writable, không id/timestamp)
Response DTO Server trả (quan hệ đã làm phẳng, trường tính toán)
@Valid Kích hoạt Bean Validation trên tham số @RequestBody
@NotBlank String không null, rỗng, hay chỉ khoảng trắng
@Size(min, max) Kích thước string/collection trong phạm vi
Custom Validator Annotation + ConstraintValidator implementation
@ControllerAdvice Xử lý exception toàn cục cho tất cả controller
@ExceptionHandler Phương thức xử lý kiểu exception cụ thể
ErrorResponse Định dạng lỗi nhất quán cho tất cả API error

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *