99 Ngày Spring — Ngày 14: Bean Validation — @Valid & bộ constraint chuẩn
Ngày 13 chốt bằng một lỗ hổng còn ngỏ: DTO kiểm soát hình dạng dữ liệu, nhưng title rỗng tuếch, email viết bậy, số lượng âm — vẫn ung dung qua cửa. Hôm nay ta dựng tuyến phòng thủ thứ hai: Bean Validation — khai báo luật ngay trên DTO bằng annotation (@NotBlank, @Size, @Email…), kích hoạt bằng đúng một chữ @Valid, và mọi vi phạm tự động thành 400 — controller không viết một chữ if nào.

@Valid + constraint — luật nằm trên hợp đồng
// build.gradle / pom.xml: thêm spring-boot-starter-validation
public record CreatePostRequest(
@NotBlank(message = "Tiêu đề không được để trống")
@Size(max = 200, message = "Tiêu đề tối đa 200 ký tự")
String title,
@NotBlank String content,
@Email(message = "Email không hợp lệ")
String contactEmail,
@Min(0) @Max(5)
int priority
) { }
@PostMapping
public Post create(@Valid @RequestBody CreatePostRequest req) {
return postService.create(req); // vào được đây = DỮ LIỆU ĐÃ SẠCH
}
// Thiếu @Valid = cả đống annotation trên DTO chỉ là VẬT TRANG TRÍ — bẫy số 1!Triết lý khai báo thay vì thủ tục: luật đứng cạnh field — đọc DTO là đọc được toàn bộ điều khoản, không phải dò 30 dòng
iftrong controller.Bẫy số 1 của mọi người mới: constraint đầy DTO nhưng quên
@Validở tham số controller — không ai kiểm tra gì cả, lặng lẽ như chưa hề có luật. Gặp "validation không chạy", soi chỗ này đầu tiên.Từ Boot 3, nhớ thêm
spring-boot-starter-validation— starter web không còn kèm sẵn như xưa; thiếu nó cũng ra đúng triệu chứng "annotation trang trí".messagetùy chỉnh cho từng luật — API tiếng Việt thân thiện ngay tại đây (i18n bài bản hẹn Ngày 68).
Bộ constraint chuẩn & bộ ba gây lú
// Bộ constraint chuẩn hay dùng nhất:
//
// Chuỗi: @NotBlank (khác null, khác "", khác " ")
// @Size(min=, max=) @Pattern(regexp = "...") @Email
// Số: @Min / @Max @Positive / @PositiveOrZero @DecimalMin("0.01")
// Ngày: @Past / @PastOrPresent @Future (sinh nhật @Past, lịch hẹn @Future)
// Khác: @NotNull (mọi kiểu) @NotEmpty (collection/chuỗi không rỗng)
//
// Phân biệt bộ ba gây lú cho String:
// @NotNull : khác null — nhưng "" vẫn qua ✓
// @NotEmpty : khác null, khác "" — nhưng " " vẫn qua ✓
// @NotBlank : khác null, khác "", khác toàn khoảng trắng — CHẶT NHẤT (Ngày 09 isBlank!)
//
// Lưu ý kiểu số: int priority + @Min là đủ;
// nhưng Integer priority @NotNull mới bắt được "thiếu field" — int mặc định 0 lọt lưới!Thuộc lòng bảng phân biệt
@NotNull/@NotEmpty/@NotBlank— câu hỏi phỏng vấn tủ, và chọn sai là bug thật: field "bắt buộc" nhận chuỗi ba dấu cách.Chi tiết
intvsIntegerlà bài Ngày 03 (giá trị mặc định) hồi sinh trong thế giới web: primitive không thể null nên "thiếu field" hóa trang thành0hợp lệ.Cần luật vượt chuẩn (mã sản phẩm nội bộ, số điện thoại VN…)?
@Patternvới regex cân được đa số; luật phức tạp nữa thì tự viết custom validator — hẹn Ngày 67.
Object lồng nhau — @Valid không tự lan
public record OrderItem(
@NotBlank String productCode,
@Min(1) int quantity
) { }
public record CreateOrderRequest(
@NotBlank String customerName,
@NotEmpty // danh sách không rỗng...
@Valid // ...VÀ validate TỪNG PHẦN TỬ — dễ quên nhất đây!
List<OrderItem> items,
@Valid ShippingAddress address // object lồng nhau: cũng cần @Valid
) { }
// Thiếu @Valid trên items → đơn 100 sản phẩm quantity = -5 vẫn lọt!
// Quy tắc: @Valid ở MỌI TẦNG muốn kiểm tra — nó không tự lan xuốngQuy tắc dễ nhớ mà hay quên:
@Validchỉ kiểm tra một tầng — muốn xuống sâu (list phần tử, object con) phải dán tiếp ở từng tầng.@NotEmptyvà@Validtrên cùng fielditemslàm hai việc khác nhau: cái trước chặn danh sách rỗng, cái sau soi từng phần tử — thiếu một trong hai đều hở.
Khi dữ liệu rớt kiểm tra
// Dữ liệu rớt kiểm tra → Spring ném MethodArgumentNotValidException
// → Boot trả 400 kèm cấu trúc lỗi mặc định.
//
// POST /api/posts { "title": "", "priority": 9 }
// HTTP/1.1 400 Bad Request
// {
// "status": 400,
// "errors": [
// { "field": "title", "message": "Tiêu đề không được để trống" },
// { "field": "priority", "message": "phải nhỏ hơn hoặc bằng 5" }
// ] // (hình dạng chính xác tùy cấu hình — mặc định hơi "thô")
// }
//
// Controller KHÔNG cần try-catch — cứ để exception bay lên.
// Ngày 16 (@ControllerAdvice + ProblemDetail) sẽ may bộ vest đẹp
// cho mọi lỗi validation: một chỗ, mọi endpoint.Toàn bộ vi phạm được gom một lượt — client sửa một lần thay vì thử-sai từng field; đó là lý do lỗi validation trả danh sách.
Phân tầng phòng thủ cho rõ vai: validation ở biên (hình thức dữ liệu — hôm nay) khác luật nghiệp vụ trong service ("số dư không đủ" — so sánh với đóng gói bên series Java hôm qua: class tự bảo vệ bất biến của nó). Đừng nhét luật nghiệp vụ vào annotation.
Bài tập nhỏ
Gắn constraint đầy đủ cho
CreateBookRequest(title@NotBlank @Size(max=100), price@Positive) — gửi request phạm cả hai luật cùng lúc, đếm số lỗi trong response.Cố tình xóa
@Valid— xác nhận mọi luật thành trang trí, rồi trả lại và thấy 400 hồi sinh.Đổi một field
intthànhInteger @NotNull— gửi JSON thiếu field đó, so sánh hành vi trước/sau.Dựng
CreateOrderRequestlồngList<OrderItem>như bài — thử cả ba ca: list rỗng, phần tử hỏng, và thiếu@Validđể chứng kiến lỗ hổng.
Kết luận
Hai tuyến phòng thủ đã khép: DTO (Ngày 13) chặn field lạ, Bean Validation hôm nay chặn giá trị bẩn — tất cả khai báo trên hợp đồng, kích hoạt bằng @Valid, nhớ dán đủ tầng và cảnh giác int vs Integer. Còn một mảnh cuối cho chu trình request tử tế: trả lời cho đúng chuẩn — tạo mới phải 201 kèm Location, không tìm thấy phải 404 thay vì 200 rỗng. Ngày 15: ResponseEntity & nghệ thuật chọn status code. Hẹn gặp lại!
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.


