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
- Tạo
CreateUserRequestDTO với validation: username (3-50 ký tự), email (hợp lệ), password (8+ ký tự). Thêm@UniqueUsername. - Tạo
PostSummaryResponseDTO nhẹ cho trang danh sách (không có content). - Xử lý
DataIntegrityViolationExceptiontrong GlobalExceptionHandler → trả 409 Conflict. - 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 |
