Jakarta Bean Validation & @Valid — declarative constraint trên DTO
Jakarta Bean Validation 3.0 (JSR 380): 23 built-in constraint, @Valid trigger và cascade nested object, @Valid vs @Validated — cơ chế AOP phía dưới, và tại sao declarative validation tách rule khỏi business logic.
TL;DR: Jakarta Bean Validation 3.0 (JSR 380) cung cấp 23 annotation constraint — @NotBlank, @Size, @Email, @Min/@Max, @Positive, @Past/@Future... Đặt annotation lên field DTO, sau đó thêm @Valid trên parameter method để Spring kích hoạt validation. Khi constraint fail, Spring ném MethodArgumentNotValidException và trả HTTP 400 trước khi method body chạy. @Valid cascade vào nested object; @Validated (Spring) bật method-level validation qua AOP proxy — khác với @Valid (Jakarta). Pitfall lớn nhất: Boot 3 tách spring-boot-starter-validation khỏi starter-web — quên add dependency thì annotation có nhưng validation silent skip.
Bài trước (Request binding) giải thích Spring map JSON body vào Java object như thế nào. Sau khi bind xong, câu hỏi tiếp theo là: dữ liệu vừa bind có hợp lệ không? Bài này trả lời đúng câu đó — cơ chế, annotation, và tại sao chọn declarative thay vì if/else manual.
1. Jakarta Bean Validation — chuẩn JSR 380
Jakarta Bean Validation 3.0 (trước đây là JSR 380, namespace javax.validation) là chuẩn Java EE định nghĩa API khai báo constraint lên field và method bằng annotation. Chuẩn này chỉ định nghĩa interface — implementation thật là Hibernate Validator, thư viện đi kèm spring-boot-starter-validation.
<!-- Boot 3+: PHAI add explicit, khong tu dong trong starter-web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Dependency này kéo theo jakarta.validation-api (spec) và hibernate-validator (implementation). Thiếu nó — annotation compile được, nhưng constraint không trigger lúc runtime.
Vì sao declarative thay if/else?
Cách viết validation thủ công:
// Manual validation -- tron vao business logic
@PostMapping("/orders")
public OrderDto create(@RequestBody OrderRequest req) {
if (req.customer() == null || req.customer().isBlank()) {
throw new IllegalArgumentException("Customer required");
}
if (req.total() == null || req.total().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Total must be positive");
}
if (req.items() == null || req.items().isEmpty()) {
throw new IllegalArgumentException("Items required");
}
// ... 10 field nua, 30 dong if/else
return orderService.create(req);
}
Mỗi field thêm một if. Rule lặp lại qua mọi controller dùng cùng DTO. Khi rule thay đổi, phải tìm sửa ở nhiều chỗ.
Declarative:
public record OrderRequest(
@NotBlank String customer,
@NotNull @Positive BigDecimal total,
@NotEmpty @Valid List<OrderItem> items
) {}
@PostMapping("/orders")
public OrderDto create(@Valid @RequestBody OrderRequest req) {
// Khong mot dong if/else -- Spring validate truoc khi vao day
return orderService.create(req);
}
Rule sống trên DTO — một nơi duy nhất. Controller, service, test đều nhìn thấy cùng constraint. Đây là nguyên lý separation of concerns: rule validation tách khỏi logic xử lý.
Validation xảy ra trước khi method body chạy — controller không bao giờ nhận object invalid.
2. 23 built-in constraint — bảng tra cứu
Jakarta Bean Validation 3.0 đi kèm 23 constraint chia theo nhóm:
Null / presence
| Annotation | Áp dụng | Ý nghĩa |
|---|---|---|
@NotNull | Object | Không được null |
@Null | Object | Phải null (dùng với validation group) |
@NotBlank | String | Không null, không empty, không toàn whitespace |
@NotEmpty | String, Collection, Map, Array | Không null, không empty |
@NotBlank mạnh hơn @NotEmpty: " " (chỉ space) fail @NotBlank nhưng pass @NotEmpty.
Number — range và sign
| Annotation | Ý nghĩa | Ví dụ |
|---|---|---|
@Min(value) | Số nguyên không nhỏ hơn value | @Min(1) |
@Max(value) | Số nguyên không lớn hơn value | @Max(100) |
@DecimalMin(value) | Số thực không nhỏ hơn value (BigDecimal precision) | @DecimalMin("0.01") |
@DecimalMax(value) | Số thực không lớn hơn value | @DecimalMax("999999.99") |
@Positive | Số dương, lớn hơn 0 | — |
@PositiveOrZero | Không âm, từ 0 trở lên | — |
@Negative | Số âm, nhỏ hơn 0 | — |
@NegativeOrZero | Không dương, từ 0 trở xuống | — |
@Digits(integer, fraction) | Tối đa N chữ số phần nguyên, M phần thập phân | @Digits(6, 2) |
String — độ dài và format
| Annotation | Ý nghĩa |
|---|---|
@Size(min, max) | Length của String, Collection, Array trong khoảng min-max |
@Email | Chuỗi hợp lệ theo format email |
@Pattern(regexp) | Match biểu thức chính quy Java |
Date / time
| Annotation | Ý nghĩa |
|---|---|
@Past | Thời điểm trong quá khứ (trước now) |
@PastOrPresent | Quá khứ hoặc hiện tại |
@Future | Thời điểm trong tương lai (sau now) |
@FutureOrPresent | Tương lai hoặc hiện tại |
Áp dụng cho LocalDate, LocalDateTime, Instant, Date, Calendar.
Boolean và cascade
| Annotation | Ý nghĩa |
|---|---|
@AssertTrue | boolean field hoặc method is*() phải trả true |
@AssertFalse | boolean field hoặc method phải trả false |
@Valid | Kích hoạt cascade — validate nested object |
Ví dụ kết hợp thực tế
public record OrderRequest(
@NotBlank(message = "Customer name is required")
@Size(min = 2, max = 100)
String customer,
@NotBlank
@Email
String email,
@NotNull
@Positive
@DecimalMax("999999.99")
BigDecimal total,
@NotNull
@Future(message = "Delivery date must be in the future")
LocalDate deliveryDate,
@NotEmpty
@Size(max = 100, message = "Maximum 100 items per order")
@Valid // cascade -- validate tung OrderItem
List<OrderItem> items,
@Pattern(regexp = "^[A-Z]{2}\\d{4}$", message = "Voucher format: 2 letters + 4 digits")
String voucherCode // nullable -- khong co @NotBlank
) {}
public record OrderItem(
@NotBlank String sku,
@Min(1) @Max(99) int quantity,
@NotNull @Positive BigDecimal unitPrice
) {}
Constraint gắn trên record component — Spring Hibernate Validator tự pick up khi @Valid kích hoạt.
3. Cơ chế bên dưới — @Valid trigger MethodArgumentNotValidException
@Valid trên method parameter không làm gì ở compile time. Tác dụng thật xảy ra lúc runtime, bên trong HandlerMethodArgumentResolver.

Bước quan trọng là DataBinder.validate() — nó được gọi chỉ khi parameter có annotation @Valid hoặc @Validated. Không có annotation đó, validate() không được gọi, Hibernate Validator không chạy — và nhánh đó không sinh ra bất kỳ tín hiệu nào để bạn nhận ra.
MethodArgumentNotValidException chứa BindingResult — danh sách FieldError, mỗi lỗi có:
field: tên field vi phạmcode: tên constraint (NotBlank,Size.min, ...)defaultMessage: message từ annotation
Bài Problem Details mổ xẻ cách build response 400 từ BindingResult thành format chuẩn RFC 9457.
@Valid cascade nested object
Nếu DTO chứa nested object, constraint bên trong nested chỉ được validate khi field đó có @Valid:
// Khong cascade -- constraint trong OrderItem bi skip
public record OrderRequest(
List<OrderItem> items
) {}
// Co cascade -- Hibernate Validator di vao tung OrderItem
public record OrderRequest(
@NotEmpty
@Valid List<OrderItem> items
) {}
Cascade hoạt động đệ quy: nếu OrderItem cũng chứa nested object có @Valid, Hibernate Validator tiếp tục đi sâu thêm.
4. @Valid vs @Validated — hai annotation khác gốc, khác mục đích
Đây là điểm gây nhầm lẫn phổ biến nhất:
| Aspect | @Valid (Jakarta) | @Validated (Spring) |
|---|---|---|
| Package | jakarta.validation | org.springframework.validation.annotation |
| Mục đích chính | Trigger validation trên parameter, cascade nested | Bật method-level validation qua AOP proxy |
| Vị trí dùng | Method parameter, field | Class, method, parameter |
| Hỗ trợ validation group | Không | Có — @Validated(Create.class) |
| Exception ném ra (controller) | MethodArgumentNotValidException | ConstraintViolationException |
@Validated class-level — bật method validation AOP
Đặt @Validated trên controller class để kích hoạt validation cho @RequestParam, @PathVariable, và parameter đơn lẻ:
@RestController
@Validated // BAT method-level validation
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping
public List<OrderDto> list(
@RequestParam @Min(0) int page,
@RequestParam @Min(1) @Max(100) int size,
@RequestParam @Email String filterEmail
) { ... }
}
Nếu không có @Validated trên class — @Min, @Max, @Email trên @RequestParam silently skip. Không có lỗi compile, không có warning — constraint âm thầm bị bỏ qua.
Cơ chế AOP phía dưới
Khi Spring thấy @Validated trên class, nó tạo một AOP proxy bao quanh class đó. Proxy intercept mọi method call, gọi MethodValidationInterceptor trước khi chuyển tiếp vào method thật:

Hai cột không gặp nhau ở đâu cả, nên ConstraintViolationException (từ @Validated AOP) khác MethodArgumentNotValidException (từ @Valid trên @RequestBody) và cả hai đều cần handler trong @RestControllerAdvice. Bài Problem Details cover cách map cả hai thành cùng format 400.
@Valid vs @Validated — quy tắc thực tế
// Quy tac nho:
// @Valid --> dat tren @RequestBody hoac field de cascade
// @Validated --> dat tren CLASS de bat method validation (param, path var)
@RestController
@Validated // (1) bat method validation cho param/path
public class OrderController {
// (2) @Valid tren @RequestBody -- trigger + cascade
@PostMapping
public OrderDto create(@Valid @RequestBody OrderRequest req) { ... }
// (3) constraint tren param work nho @Validated class-level o tren
@GetMapping("/{id}")
public OrderDto findById(@PathVariable @NotNull @Positive Long id) { ... }
}
5. Pitfall thường gặp
Pitfall 1 — Quên @Valid trên @RequestBody
// SAI -- constraint tren OrderRequest bi skip hoan toan
@PostMapping("/orders")
public OrderDto create(@RequestBody OrderRequest req) { ... }
// DUNG
@PostMapping("/orders")
public OrderDto create(@Valid @RequestBody OrderRequest req) { ... }
Đây là pitfall số 1: annotation @NotBlank, @Size trên OrderRequest compile tốt nhưng không có hiệu lực. Invalid data đi thẳng vào service.
Pitfall 2 — Quên @Valid cascade nested object
// SAI -- OrderItem khong duoc validate du co @NotBlank @Min
public record OrderRequest(
@NotEmpty List<OrderItem> items // thieu @Valid
) {}
// DUNG
public record OrderRequest(
@NotEmpty @Valid List<OrderItem> items
) {}
Pitfall 3 — Quên @Validated trên controller class cho @RequestParam
// SAI -- @Min tren page silent skip
@RestController // thieu @Validated
public class OrderController {
@GetMapping
public List<OrderDto> list(@RequestParam @Min(0) int page) { ... }
}
// DUNG
@RestController
@Validated
public class OrderController {
@GetMapping
public List<OrderDto> list(@RequestParam @Min(0) int page) { ... }
}
Pitfall 4 — Thiếu dependency Boot 3
<!-- Boot 3 TACH ra -- phai add explicit -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Không add dependency thì annotation vẫn compile OK (API jar có sẵn trong classpath qua starter-web transitive), nhưng Hibernate Validator vắng mặt khiến ValidationAutoConfiguration không active — constraint không bao giờ trigger.
Cách kiểm tra nhanh:
mvn dependency:tree | grep hibernate-validator
# Phai thay: hibernate-validator-X.Y.Z.Final.jar
Ba pitfall đầu (thiếu @Valid, thiếu cascade, thiếu @Validated) và thiếu dependency đều gây ra cùng triệu chứng: invalid data đi qua mà không có lỗi. Không có exception, không có log warning. Cách phát hiện đáng tin cậy nhất: viết integration test gửi request invalid, assert response là 400.
Liên hệ các bài khác
- Request binding: bind JSON thành Java object là bước trước validation —
@Validchạy sau khi Jackson deserialize xong, trên object đã được bind. - Problem Details: khi
@Validtrigger và constraint fail, Spring némMethodArgumentNotValidException. Bài đó mổ xẻ cách@RestControllerAdvicebắt exception này và build response 400 theo RFC 9457 Problem Details — đây là nơiBindingResultđược đọc và format. - Validation advanced: bài tiếp theo đào sâu validation groups (
@Validated(Create.class)cho POST vs PUT khác rule), custom@Constraintannotation cho domain-specific rule, và i18n message quamessages.properties.
Tóm tắt
- Jakarta Bean Validation 3.0 (JSR 380) là chuẩn Java cho declarative constraint. Hibernate Validator là implementation, đi với
spring-boot-starter-validation— Boot 3 tách riêng, phải add explicit. - Declarative thắng manual: rule sống trên DTO — một nguồn sự thật. Không
if/elsescattered qua controller. - 23 built-in constraint nhóm theo: null/presence (
@NotNull,@NotBlank,@NotEmpty), number (@Min,@Max,@Positive,@Digits...), string (@Size,@Email,@Pattern), date (@Past,@Future...), boolean (@AssertTrue), cascade (@Valid). @Validtrigger validation: đặt trên@RequestBodyparameter khiếnDataBinder.validate()chạy; constraint fail némMethodArgumentNotValidExceptionvà client nhận 400 trước khi method body thực thi.@Validcascade: thêm@Validtrên field collection/nested object trong DTO để Hibernate Validator đi vào từng phần tử và validate constraint bên trong.@Validvs@Validated:@Valid(Jakarta) trigger/cascade;@Validated(Spring) class-level bật AOP proxy cho method-level validation — cần cho@RequestParam/@PathVariableconstraint.- Silent skip là bug class: thiếu
@Valid, thiếu cascade, thiếu@Validated, hoặc thiếu dependency đều cho kết quả giống nhau — invalid data qua mà không cảnh báo.
Tự kiểm tra
- Q1Vì sao Jakarta Bean Validation chọn cách khai báo constraint bằng annotation thay vì yêu cầu dev viết logic validate bằng
if/elsetrong mỗi method? Lợi ích thiết kế cụ thể là gì? - Q2Đoạn code sau có mấy vấn đề? Liệt kê đủ và giải thích mỗi vấn đề gây ra hậu quả gì.
@RestController @RequestMapping("/api/products") public class ProductController { @PostMapping public ProductDto create(@RequestBody ProductRequest req) { return service.create(req); } @GetMapping public List<ProductDto> list( @RequestParam @Min(0) int page, @RequestParam @Min(1) @Max(50) int size ) { return service.list(page, size); } } public record ProductRequest( @NotBlank String name, @Positive BigDecimal price, @NotEmpty @Valid List<CategoryRef> categories ) {} public record CategoryRef( @NotNull Long id, @NotBlank String code ) {} - Q3Giải thích chính xác điều gì xảy ra bên dưới khi Spring xử lý request
POST /ordersvới body JSON invalid — từ lúc request đến khi trả response 400. Nhắc đến đúng class Java tham gia. - Q4Sự khác biệt thực tế giữa
@Valid(Jakarta) và@Validated(Spring) là gì? Khi nào dùng cái nào? - Q5DTO
ShipmentRequestdưới có fieldpackageslà list nested object. Constraint nào trênPackageItemsẽ được validate và constraint nào bị skip? Tại sao? Sửa để validate đúng.public record ShipmentRequest( @NotBlank String trackingNumber, @Future LocalDate estimatedArrival, @Size(min = 1, max = 50) List<PackageItem> packages ) {} public record PackageItem( @NotBlank String description, @Positive double weightKg, @Min(1) int quantity ) {}
Bài tiếp theo: Validation groups, custom constraint & i18n
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên