jakarta bean validation & spring validation
Tung Bui Thanh (TungDaDev)11 mins read

# giới thiệu
"Never trust user input" là nguyên tắc security mà dev nào cũng từng nghe. Data nào từ bên ngoài vào (HTTP request, message queue, file import) cũng phải validate trước khi xử lý. Validate không chỉ để chống injection, mà còn để business rule được áp ngay từ cửa vào.
Spring Boot hỗ trợ sẵn Jakarta Bean Validation (bản implement là Hibernate Validator), cho phép validate kiểu khai báo bằng annotation. Thay vì viết cả chục dòng if (field == null || field.isEmpty()), bạn gắn @NotBlank rồi để framework lo. Lưu ý là từ Spring Boot 2.3, spring-boot-starter-web không còn kéo theo validation nữa, bạn phải tự thêm spring-boot-starter-validation. Thiếu dependency này thì annotation vẫn biên dịch bình thường nhưng không có gì được kiểm tra cả, lỗi kiểu này rất khó phát hiện.
Làm lâu rồi thì cách tổ chức validation quan trọng hơn từng annotation riêng lẻ: validate ở đâu (controller, service hay entity), viết custom constraint cho business rule, dùng validation group cho từng thao tác khác nhau, và làm error message đa ngôn ngữ (i18n).
# standard validation annotations
Đây là mấy annotation bạn gắn lên DTO hằng ngày. Chúng nằm trong jakarta.validation.constraints (trước đây là javax.validation).
@Data
public class CreateProductRequest {
// String validations
@NotNull(message = "Name must not be null")
@NotBlank(message = "Name must not be blank") // not null + not empty + not whitespace
@Size(min = 2, max = 255, message = "Name must be 2-255 characters")
private String name;
@NotBlank
@Pattern(regexp = "^[A-Z]{2,5}-\\d{3,6}$", message = "Code format: XX-000 to XXXXX-000000")
private String code;
@Email(message = "Invalid email format")
private String contactEmail;
@Size(max = 2000, message = "Description max 2000 characters")
private String description;
// Numeric validations
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
@DecimalMax(value = "99999999.99", message = "Price exceeds maximum")
private BigDecimal price;
@Min(value = 0, message = "Quantity cannot be negative")
@Max(value = 100000, message = "Quantity exceeds maximum")
private Integer quantity;
@PositiveOrZero
private BigDecimal discount;
// Date validations
@Future(message = "Expiry date must be in the future")
private LocalDate expiryDate;
@PastOrPresent(message = "Created date cannot be in the future")
private LocalDateTime createdAt;
// Collection validations
@NotEmpty(message = "At least one tag is required")
@Size(max = 10, message = "Maximum 10 tags")
private List<@NotBlank(message = "Tag cannot be blank") String> tags;
// Nested object validation
@Valid // Trigger validation cho nested object
@NotNull(message = "Category is required")
private CategoryRef category;
// Boolean
@NotNull(message = "Must accept terms") // @AssertTrue coi null là hợp lệ
@AssertTrue(message = "Must accept terms")
private Boolean termsAccepted;
}
@Data
public class CategoryRef {
@NotNull
private UUID id;
@NotBlank
@Size(max = 50)
private String code;
}# tra nhanh các constraint có sẵn
| Annotation | Dùng cho | Kiểm tra |
|---|---|---|
| @NotNull | Mọi kiểu | != null |
| @NotBlank | String | != null, không rỗng, không chỉ toàn khoảng trắng |
| @NotEmpty | String/Collection/Map/Array | != null, size > 0 |
| @Size(min,max) | String/Collection/Map/Array | Kích thước nằm trong khoảng |
| @Min(value) | Numeric | >= value |
@Max(value) | Numeric | <= value |
| @Positive | Numeric | > 0 |
| @PositiveOrZero | Numeric | >= 0 |
| @Negative | Numeric | < 0 |
| @DecimalMin | Numeric | >= value (value khai báo dạng String) |
| @DecimalMax | Numeric | <= value |
| @Digits(integer,fraction) | Numeric | Giới hạn số chữ số |
| String | Đúng định dạng email (kiểm tra khá lỏng) | |
| @Pattern(regexp) | String | Khớp regex |
| @Past | Date/Time | Trước thời điểm hiện tại |
| @PastOrPresent | Date/Time | Trước hoặc bằng hiện tại |
| @Future | Date/Time | Sau thời điểm hiện tại |
| @FutureOrPresent | Date/Time | Sau hoặc bằng hiện tại |
| @AssertTrue | Boolean | Phải là true |
| @AssertFalse | Boolean | Phải là false |
Có một quy ước dễ bị bỏ qua: trừ nhóm @NotNull/@NotBlank/@NotEmpty, mọi constraint còn lại đều coi null là hợp lệ. @Email, @Future, @Min, kể cả @AssertTrue đều cho null đi qua. Spec cố ý thiết kế vậy để mỗi annotation chỉ lo một việc: field tuỳ chọn thì chỉ cần kiểm tra định dạng khi có giá trị, field bắt buộc thì gắn thêm @NotNull. Vì thế termsAccepted ở ví dụ trên cần cả hai annotation, chỉ có @AssertTrue thì client bỏ trống field là lọt.
Còn @Email của Hibernate Validator kiểm tra rất lỏng, chuỗi như a@b vẫn hợp lệ. Muốn chặt hơn thì thêm regexp vào @Email hoặc kết hợp @Pattern, nhưng cách chắc nhất vẫn là gửi email xác nhận.
# custom constraint: validate theo business rule
Annotation có sẵn chỉ lo được mấy trường hợp chung chung. Business rule riêng thì phải tự viết constraint. Ví dụ: kiểm tra định dạng số tài khoản ngân hàng, mã số thuế, số điện thoại Việt Nam.
Cách làm: tạo một annotation và một class validator đi kèm.
// === Custom annotation ===
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidPhoneNumberValidator.class)
@Documented
public @interface ValidPhoneNumber {
String message() default "Invalid Vietnamese phone number";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// === Validator implementation ===
public class ValidPhoneNumberValidator implements ConstraintValidator<ValidPhoneNumber, String> {
private static final Pattern VN_PHONE = Pattern.compile("^(\\+84|0)(3|5|7|8|9)\\d{8}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // @NotNull handles null — separation of concerns
return VN_PHONE.matcher(value).matches();
}
}
// === Usage ===
@Data
public class CreateCustomerRequest {
@NotBlank
private String name;
@NotBlank
@ValidPhoneNumber
private String phoneNumber;
}# validate nhiều field cùng lúc: constraint ở mức class
Khi phải so nhiều field với nhau (nhập lại password, khoảng ngày, field bắt buộc tuỳ điều kiện):
// Annotation trên class level
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidDateRangeValidator.class)
public @interface ValidDateRange {
String message() default "End date must be after start date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String startField();
String endField();
}
// Validator
public class ValidDateRangeValidator implements ConstraintValidator<ValidDateRange, Object> {
private String startField;
private String endField;
@Override
public void initialize(ValidDateRange annotation) {
this.startField = annotation.startField();
this.endField = annotation.endField();
}
@Override
public boolean isValid(Object obj, ConstraintValidatorContext ctx) {
try {
LocalDate start = (LocalDate) BeanUtils.getPropertyDescriptor(obj.getClass(), startField)
.getReadMethod().invoke(obj);
LocalDate end = (LocalDate) BeanUtils.getPropertyDescriptor(obj.getClass(), endField)
.getReadMethod().invoke(obj);
if (start == null || end == null) return true;
if (end.isAfter(start)) return true;
// Gắn lỗi vào field endField thay vì cả object,
// để handler đọc getFieldErrors() vẫn thấy lỗi này
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
.addPropertyNode(endField)
.addConstraintViolation();
return false;
} catch (ReflectiveOperationException e) {
// Sai tên field trong annotation là lỗi lập trình, đừng biến nó thành lỗi validation
throw new IllegalStateException(e);
}
}
}
// Usage
@Data
@ValidDateRange(startField = "startDate", endField = "endDate")
public class ReportRequest {
@NotNull
private LocalDate startDate;
@NotNull
private LocalDate endDate;
}# validation group: mỗi thao tác một bộ luật
Cùng 1 DTO mà Create với Update cần validate khác nhau: Create thì không được có ID, Update thì bắt buộc có ID. Validation Groups sinh ra cho đúng chuyện này.
// Define groups
public interface OnCreate {}
public interface OnUpdate {}
// DTO với groups
@Data
public class ProductRequest {
@Null(groups = OnCreate.class, message = "ID must be null when creating")
@NotNull(groups = OnUpdate.class, message = "ID is required when updating")
private UUID id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
private String name;
@NotNull(groups = OnCreate.class, message = "Price required on creation")
private BigDecimal price;
@NotBlank(groups = OnCreate.class)
private String code; // Required only on create, immutable after
}
// Controller specifies which group to validate
@PostMapping
public ProductDTO create(@Validated(OnCreate.class) @RequestBody ProductRequest request) { ... }
@PutMapping("/{id}")
public ProductDTO update(
@PathVariable UUID id,
@Validated(OnUpdate.class) @RequestBody ProductRequest request) { ... }Chỗ hay vấp: constraint nào không khai báo groups thì thuộc group Default. Khi controller gọi @Validated(OnCreate.class), chỉ constraint của OnCreate được chạy, mấy constraint Default bị bỏ qua hết mà không báo gì. Vì vậy trong ví dụ trên field nào cũng ghi rõ group. Nếu muốn constraint không gắn group vẫn chạy theo, cho group kế thừa Default: public interface OnCreate extends Default {} (jakarta.validation.groups.Default).
# validate ở mức method: gắn @Validated lên class
Validate luôn tham số của method (chứ không riêng @RequestBody). Rất tiện khi muốn validate ở service layer. Spring Boot tự đăng ký MethodValidationPostProcessor, nó bọc bean có @Validated trong một proxy và kiểm tra tham số trước khi gọi vào method thật. Vì là proxy nên method gọi nội bộ (this.updatePrice(...) từ chính class đó) sẽ không được validate, giống chuyện @Transactional.
@Service
@Validated // Enable method-level validation
public class ProductService {
public ProductDTO getById(@NotNull UUID id) {
return productRepository.findById(id)
.map(this::toDTO)
.orElseThrow();
}
public Page<ProductDTO> search(
@Size(min = 2, max = 100) String keyword,
@Min(0) int page,
@Min(1) @Max(100) int size) {
return productRepository.search(keyword, PageRequest.of(page, size)).map(this::toDTO);
}
public void updatePrice(
@NotNull UUID id,
@Positive @DecimalMax("99999999.99") BigDecimal newPrice) {
// ConstraintViolationException nếu params invalid
}
}# error response: trả lỗi validation cho gọn gàng
@RestControllerAdvice
public class ValidationExceptionHandler {
// @RequestBody validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public APIResponse<List<FieldError>> handleValidation(MethodArgumentNotValidException ex) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage(), fe.getRejectedValue()))
.toList();
return APIResponse.error("VALIDATION_ERROR", "Input validation failed", errors);
}
// Method param validation errors (@Validated on class)
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public APIResponse<List<String>> handleConstraint(ConstraintViolationException ex) {
List<String> errors = ex.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.toList();
return APIResponse.error("CONSTRAINT_ERROR", "Parameter validation failed", errors);
}
@Data
@AllArgsConstructor
public static class FieldError {
private String field;
private String message;
private Object rejectedValue;
}
}Hai exception này tương ứng hai đường validate khác nhau. @Valid/@Validated trên @RequestBody ném MethodArgumentNotValidException, lỗi nằm trong BindingResult. Validate theo method trên bean có @Validated ném ConstraintViolationException của Jakarta. Lưu ý getFieldErrors() chỉ trả lỗi gắn với field; lỗi của constraint mức class nằm ở getGlobalErrors(), đó là lý do validator ValidDateRange phía trên gắn lỗi vào endField.
Từ Spring Framework 6.1 (Spring Boot 3.2), controller còn có method validation dựng sẵn: gắn constraint thẳng lên @PathVariable/@RequestParam mà không cần @Validated trên class, và khi đó lỗi ra dưới dạng HandlerMethodValidationException. Dùng cách này thì nhớ thêm một handler cho nó, không thì client nhận lỗi 400 mặc định của Spring thay vì format của bạn.
Response trả về trông như sau:
{
"status_code": 400,
"error_code": "VALIDATION_ERROR",
"message": "Input validation failed",
"data": [
{
"field": "name",
"message": "Name must not be blank",
"rejectedValue": ""
},
{
"field": "price",
"message": "Price must be positive",
"rejectedValue": -10
},
{
"field": "code",
"message": "Code format: XX-000 to XXXXX-000000",
"rejectedValue": "abc"
}
]
}# mấy kinh nghiệm khi làm validation
- Validate ở biên: ở DTO của controller (đầu vào) và trước khi gọi service bên ngoài (đầu ra)
- Business rule thì viết custom constraint: đừng validate business logic bằng if/else trong service
- @NotNull vs @NotBlank vs @NotEmpty: nắm cho rõ khác nhau ở đâu (null, "", " " )
- Custom constraint KHÔNG nên query DB: tốn connection pool vô ích, mà kiểm tra trước rồi insert sau vẫn có thể bị trùng khi hai request chạy song song. Kiểm tra trùng lặp thì để service layer làm, và chốt bằng unique constraint ở database
- Message đa ngôn ngữ (i18n): ghi message dạng key, ví dụ
@NotBlank(message = "{product.name.required}"), rồi khai báo nội dung trongValidationMessages.properties(hoặcmessages.propertiescủa Spring, vì Spring Boot nốiMessageSourcevào validator) thay vì hardcode chuỗi tiếng Anh. Không ghimessagethì Hibernate Validator dùng sẵn key mặc định như{jakarta.validation.constraints.NotBlank.message}, đã có bản dịch cho nhiều ngôn ngữ - Fail fast: validate input trước tiên, trước khi chạy bất kỳ business logic nào
- Tách request DTO: Create ≠ Update ≠ Patch. Mỗi thao tác có bộ luật validate riêng
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.