Validation: @Valid, @Validated và custom constraint
Tiếp đi: validation. Client gửi order có quantity = -5 và lines rỗng mà service vẫn nhận. Mình có nên viết if trong service không?
Viết if cho rule nghiệp vụ thì đúng chỗ, nhưng kiểm tra định dạng input thì để Bean Validation làm. Mình tách rõ hai loại.
1. Bài toán
POST /api/orders
{"customerId": null, "lines": [], "note": "<2000 ký tự>"}
Request này lọt tới OrderService.create, tạo order 0 đồng, rồi NPE ở chỗ tính phí ship → 500.
2. Vấn đề
- Check bằng tay rải rác ở controller và service, mỗi chỗ một kiểu, quên là lọt.
- Lỗi trả về không thống nhất: chỗ 500, chỗ 400 với message tiếng Anh lẫn tiếng Việt.
- Client chỉ biết lỗi đầu tiên, sửa xong gửi lại mới biết lỗi thứ hai.
3. Giải pháp
- Bean Validation (Jakarta Validation, cài đặt là Hibernate Validator): khai báo constraint trên DTO bằng annotation.
- @Valid trên tham số controller: Spring validate trước khi gọi method, gom tất cả lỗi.
- @Valid trên field: validate lồng nhau (object con, phần tử collection).
- ConstraintValidator: tự viết rule riêng.
- @Validated trên class: validate tham số method của bean bất kỳ (service) qua AOP proxy, và hỗ trợ groups.
4. Code từng bước
Bước 1: dependency (từ Boot 2.3 không còn kèm sẵn trong starter-web):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Bước 2: constraint trên DTO.
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty @Size(max = 50) List<@Valid OrderLineRequest> lines,
@Valid @NotNull ShippingAddress shippingAddress,
@Size(max = 500) String note,
@ValidCouponCode String couponCode) {}
public record OrderLineRequest(
@NotNull Long productId,
@Positive @Max(100) int quantity) {}
public record ShippingAddress(
@NotBlank String street,
@NotBlank String city,
@Pattern(regexp = "^0\\d{9}$", message = "phone must be 10 digits") String phone) {}
Bước 3: custom constraint + controller.
@Target({FIELD, PARAMETER, RECORD_COMPONENT})
@Retention(RUNTIME)
@Constraint(validatedBy = CouponCodeValidator.class)
public @interface ValidCouponCode {
String message() default "invalid coupon format";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class CouponCodeValidator implements ConstraintValidator<ValidCouponCode, String> {
private static final Pattern P = Pattern.compile("^[A-Z0-9]{6,12}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
return value == null || P.matcher(value).matches(); // null để @NotNull lo
}
}
@PostMapping
public OrderResponse create(@Valid @RequestBody CreateOrderRequest req) {
return orderService.create(req);
}
@GetMapping("/{id}")
public OrderResponse get(@PathVariable @Positive Long id) { // Spring 6.1: tự validate
return orderService.get(id);
}
Bước 4: test bằng curl.
curl -i -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
-d '{"customerId":null,"lines":[{"productId":1,"quantity":-5}],"shippingAddress":{"street":"","city":"HN"}}'
HTTP/1.1 400
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Validation failed",
"instance": "/api/orders",
"errors": [
{"field": "customerId", "message": "must not be null"},
{"field": "lines[0].quantity", "message": "must be greater than 0"},
{"field": "shippingAddress.street", "message": "must not be blank"}
]
}
Field errors là do mình thêm trong @RestControllerAdvice (bài 4.2.7). Mặc định Spring chỉ trả ProblemDetail chung chung.
5. Cách hoạt động
Controller layer
@Valid @RequestBody CreateOrderRequest
│ Jackson đọc body → object
│ RequestResponseBodyMethodProcessor: thấy @Valid → Validator.validate(obj)
│ ├─ duyệt constraint, gặp @Valid trên field/phần tử → đi sâu
│ └─ gom TẤT CẢ lỗi vào BindingResult
└─ có lỗi → MethodArgumentNotValidException → 400
@PathVariable @Positive Long id (Spring 6.1+, constraint thẳng trên tham số)
└─ lỗi → HandlerMethodValidationException → 400
Service layer
@Validated class OrderService → Spring bọc proxy (MethodValidationPostProcessor)
orderService.create(@Valid req)
└─ lỗi → ConstraintViolationException → mặc định 500 nếu không handle!
6. So sánh
@Valid (Jakarta) | @Validated (Spring) | |
|---|---|---|
| Groups | Không | Có: @Validated(OnCreate.class) |
| Đặt trên field để validate lồng | Có | Không |
| Trên class để bật method validation | Không | Có |
Trên tham số @RequestBody | Có | Có |
| Constraint | null | "" | " " |
|---|---|---|---|
@NotNull | lỗi | ok | ok |
@NotEmpty | lỗi | lỗi | ok |
@NotBlank | lỗi | lỗi | lỗi |
7. Khi nào nên dùng / không nên dùng
- Bean Validation: định dạng, bắt buộc, độ dài, khoảng giá trị. Không cần DB.
- Code trong service: rule nghiệp vụ cần dữ liệu (còn hàng không, coupon còn hạn không, order đã PAID thì không hủy được). Ném business exception.
- Không gọi DB trong
ConstraintValidatortrừ khi thật cần: chậm, khó test, và dễ race condition (check "email chưa tồn tại" vẫn phải có unique constraint ở DB).
MethodArgumentNotValidException, service ném OrderNotFoundException, ConstraintViolationException lại ra 500. Cần một chỗ map tất cả exception thành response lỗi thống nhất.Mình có @NotEmpty List<OrderLineRequest> lines rồi mà quantity = -5 bên trong vẫn lọt. Sao vậy?
Đúng rồi, validation không tự đi sâu. @NotEmpty chỉ kiểm tra list có phần tử. Muốn validate từng phần tử phải đánh dấu cascade:
@NotEmpty List<@Valid OrderLineRequest> lines // container element (khuyên dùng)
@Valid @NotEmpty List<OrderLineRequest> lines // kiểu cũ, cũng chạy
Tương tự với object con: @Valid ShippingAddress shippingAddress. Thiếu @Valid thì mọi constraint trong ShippingAddress bị bỏ qua, không báo gì.
Tạo order thì không được gửi id, còn update thì bắt buộc có id. Một DTO dùng cho cả hai được không?
Được, bằng validation groups:
public interface OnCreate {}
public interface OnUpdate {}
public record OrderRequest(
@Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) Long id,
@NotNull(groups = {OnCreate.class, OnUpdate.class}) Long customerId) {}
@PostMapping
public OrderResponse create(@Validated(OnCreate.class) @RequestBody OrderRequest r) { ... }
@PutMapping("/{id}")
public OrderResponse update(@Validated(OnUpdate.class) @RequestBody OrderRequest r) { ... }
Lưu ý: khi chỉ định group, constraint không ghi group (thuộc Default) sẽ không chạy. Đây là nguồn bug hay gặp.
Thực tế mình khuyên tách CreateOrderRequest và UpdateOrderRequest: dễ đọc hơn, contract API rõ hơn. Groups hợp khi DTO lớn và khác nhau rất ít.
Controller đã @Valid rồi, có cần @Validated ở service nữa không?
Tùy service đó được gọi từ đâu. Nếu OrderService.create còn được gọi từ Kafka consumer hay job, không đi qua controller, thì validation ở controller không bảo vệ nó.
@Service
@Validated
public class OrderService {
public OrderResponse create(@Valid CreateOrderRequest req) { ... }
public OrderResponse get(@NotNull @Positive Long id) { ... }
}
Hai điểm cần nhớ:
- Chạy qua proxy AOP, nên gọi nội bộ
this.create(...)sẽ không validate (cùng bẫy với@Transactional, xem 4.1.3). - Ném
ConstraintViolationException, không phảiMethodArgumentNotValidException. Phải handle riêng, không thì thành 500.
Phỏng vấn hỏi về validation trong Spring thì trả lời sao?
Q1. @Valid và @Validated khác nhau thế nào?
🇻🇳 Tiếng Việt:
@Valid là chuẩn Jakarta, dùng để kích hoạt validation trên tham số và để cascade vào object con hoặc phần tử collection. @Validated là của Spring, hỗ trợ validation groups và đặt trên class để bật method validation qua proxy, ví dụ validate tham số của service. Trên controller, @Valid lỗi thì ra MethodArgumentNotValidException; method validation ở service lỗi thì ra ConstraintViolationException.
🇬🇧 English:
@Valid is the Jakarta standard; it triggers validation on a parameter and cascades into nested objects or collection elements. @Validated is Spring's; it supports validation groups and, placed on a class, enables method validation through a proxy, for example on service parameters. On a controller a failed @Valid raises MethodArgumentNotValidException, while service method validation raises ConstraintViolationException.
Q2. Validation nên đặt ở tầng nào?
🇻🇳 Tiếng Việt:
Validation định dạng như bắt buộc, độ dài, khoảng giá trị đặt trên DTO và chạy ở controller để fail sớm và trả 400 với đủ danh sách lỗi. Rule nghiệp vụ cần dữ liệu như tồn kho hay trạng thái order đặt trong service và ném business exception. Ràng buộc toàn vẹn như unique vẫn phải có ở database vì check ở app có race condition.
🇬🇧 English:
Format validation such as required, length and ranges lives on the DTO and runs at the controller, so we fail fast with a 400 listing all errors. Business rules that need data, like stock or order status, live in the service and throw business exceptions. Integrity constraints like uniqueness must still exist in the database, because application checks are subject to race conditions.
Q3. Viết custom constraint thế nào?
🇻🇳 Tiếng Việt:
Tạo annotation có @Constraint(validatedBy = ...) với ba thuộc tính message, groups, payload, rồi viết class implement ConstraintValidator với method isValid. Thường trả true khi giá trị null để @NotNull lo phần bắt buộc. Nếu rule liên quan nhiều field, như từ ngày nhỏ hơn đến ngày, thì đặt constraint ở mức class.
🇬🇧 English:
I create an annotation with @Constraint(validatedBy = ...) and the message, groups and payload attributes, then a class implementing ConstraintValidator with an isValid method. It usually returns true for null so that @NotNull handles presence. If the rule spans several fields, like a start date before an end date, I use a class-level constraint.
Cách nhớ ngắn gọn
- DTO + annotation +
@Validở controller → 400 với tất cả lỗi. - Lồng nhau phải có
@Valid(List<@Valid T>). @Validated: groups + method validation ở service (proxy, ConstraintViolationException).- Định dạng → annotation; nghiệp vụ → service; unique → DB.
Tự kiểm tra
@NotEmpty có chặn chuỗi " " không?
Không. Cần @NotBlank.
Thiếu @Valid trên field ShippingAddress thì sao?
Constraint bên trong ShippingAddress không chạy, không báo lỗi.
Dùng @Validated(OnCreate.class), constraint không ghi group có chạy không?
Không, chúng thuộc group Default.
Service @Validated lỗi validation ném exception gì, status mặc định?
ConstraintViolationException, thành 500 nếu không có handler.
Vì sao check "email chưa tồn tại" bằng validator vẫn cần unique index?
Hai request đồng thời đều check qua rồi cùng insert (race condition).