Validation trong Spring Boot với Bean Validation (@Valid, @NotBlank, …)

1. Tổng quan

Khi xây REST API, bạn luôn cần kiểm tra dữ liệu đầu vào trước khi xử lý nghiệp vụ: email đúng format, tên không được rỗng, tuổi phải dương… Bean Validation (JSR 380, implement là Hibernate Validator) giúp bạn làm việc này bằng annotation, thay vì viết hàng loạt if-else thủ công.

Bài này đi qua cách cấu hình, dùng @Valid cùng các annotation phổ biến, validate nested object, viết custom validator, và xử lý lỗi validation để trả response thân thiện cho client. Đọc xong, bạn sẽ tự tin áp dụng validation cho mọi DTO trong dự án Spring Boot.

2. Thêm dependency

Spring Boot không tự kèm Bean Validation, bạn cần thêm spring-boot-starter-validation:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Dependency này kéo theo Hibernate Validator – implementation phổ biến nhất của Bean Validation API. Nếu bạn dùng spring-boot-starter-web, nhiều người tưởng validation đã có sẵn, nhưng thực tế từ Spring Boot 2.3 trở đi, starter này đã tách riêng – thiếu nó, @Valid sẽ không có tác dụng và không báo lỗi gì, dữ liệu sai vẫn đi qua.

3. Validate request body với @Valid

3.1. Khai báo DTO với constraint annotation

public record CreateOrderRequest(
        @NotBlank(message = "Tên khách hàng không được để trống")
        String customerName,

        @Email(message = "Email không hợp lệ")
        @NotBlank
        String email,

        @Positive(message = "Số lượng phải lớn hơn 0")
        Integer quantity,

        @DecimalMin(value = "0.0", inclusive = false, message = "Giá phải lớn hơn 0")
        BigDecimal unitPrice
) {}

Mỗi field gắn một hoặc nhiều constraint annotation: @NotBlank không cho null/rỗng/chỉ-chứa-khoảng-trắng, @Email kiểm tra format email, @Positive yêu cầu số dương, @DecimalMin giới hạn giá trị tối thiểu. Tham số message là nội dung lỗi trả về khi vi phạm.

3.2. Áp dụng @Valid ở controller

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<String> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        // request đã được đảm bảo hợp lệ tại đây
        return ResponseEntity.ok("Order created for " + request.customerName());
    }
}

@Valid đặt trước @RequestBody báo cho Spring MVC chạy validation trên object vừa deserialize từ JSON. Nếu có field vi phạm constraint, Spring ném MethodArgumentNotValidException trước khi method body chạy – nghĩa là bạn không cần tự check null hay rỗng trong logic nghiệp vụ nữa.

Gửi request thiếu customerName:

POST /api/orders
{
  "email": "a@b.com",
  "quantity": 2,
  "unitPrice": 10.5
}

Mặc định (chưa xử lý exception), Spring trả HTTP 400 với body khá dài và khó đọc cho client. Mục 5 sẽ xử lý việc này gọn hơn.

4. Các annotation thường dùng

Annotation Ý nghĩa
@NotNull Giá trị không được null
@NotBlank String không null, không rỗng, không chỉ chứa khoảng trắng
@NotEmpty Collection/String/Array không null và không rỗng
@Size(min, max) Độ dài String hoặc kích thước Collection
@Min / @Max Giới hạn số nguyên
@Positive / @Negative Số dương/âm
@Email Format email
@Pattern(regexp) Khớp regex
@Past / @Future Ngày trong quá khứ/tương lai

Ví dụ kết hợp nhiều constraint trên một field:

public class Customer {

    @Size(min = 3, max = 50, message = "Tên phải từ 3 đến 50 ký tự")
    private String name;

    @Pattern(regexp = "^0\\d{9}$", message = "Số điện thoại phải có 10 số, bắt đầu bằng 0")
    private String phone;

    @Past(message = "Ngày sinh phải trong quá khứ")
    private LocalDate birthDate;
}

Bạn có thể gắn nhiều annotation trên cùng field – ví dụ @NotBlank kết hợp @Size – để vừa chặn rỗng vừa giới hạn độ dài. Validator chạy tất cả constraint và gom lỗi lại, không dừng ở lỗi đầu tiên.

5. Xử lý lỗi validation với @ExceptionHandler

Thay vì để Spring trả response mặc định rối rắm, bạn dùng @RestControllerAdvice để format lại:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationErrors(
            MethodArgumentNotValidException ex) {

        Map<String, String> errors = new HashMap<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return ResponseEntity.badRequest().body(errors);
    }
}

MethodArgumentNotValidException chứa BindingResult với danh sách FieldError – mỗi lỗi có tên field và message. Đoạn code trên gom tất cả lỗi vào một Map<field, message> rồi trả HTTP 400.

Kết quả gọi lại request thiếu customerName ở trên:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "customerName": "Tên khách hàng không được để trống"
}

Response giờ ngắn gọn, client dễ map lỗi vào từng field trên form.

6. Validate nested object và validate collection

6.1. Nested object với @Valid

public record AddressRequest(
        @NotBlank String city,
        @NotBlank String street
) {}

public record CustomerRequest(
        @NotBlank String name,

        @Valid
        @NotNull
        AddressRequest address
) {}

Nếu CustomerRequest.address chỉ gắn @NotNull mà thiếu @Valid, các constraint bên trong AddressRequest (như @NotBlank trên city) sẽ không được kiểm tra – đây là lỗi rất phổ biến khi mới dùng Bean Validation. @Valid ở đây yêu cầu validator “đi sâu” vào object con.

6.2. Validate từng phần tử trong List

public record BulkOrderRequest(
        @NotEmpty(message = "Danh sách item không được rỗng")
        @Valid
        List<@Valid OrderItem> items
) {}

public record OrderItem(
        @NotBlank String productCode,
        @Positive Integer quantity
) {}

@Valid đặt ngay trước List<...> (hoặc trên type argument) báo validator kiểm tra từng phần tử trong list, không chỉ kiểm tra list có null hay không. Nếu item thứ 2 trong list có quantity = -1, lỗi trả về sẽ chỉ rõ field dạng items[1].quantity.

7. Viết custom validator

Khi constraint có sẵn không đủ, bạn tự tạo annotation riêng. Ví dụ kiểm tra mã đơn hàng phải bắt đầu bằng ORD-:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderCodeValidator.class)
public @interface ValidOrderCode {
    String message() default "Mã đơn hàng phải có dạng ORD-xxxxx";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class OrderCodeValidator implements ConstraintValidator<ValidOrderCode, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // để @NotNull xử lý riêng, tránh trùng lỗi
        }
        return value.matches("^ORD-\\d{5}$");
    }
}

@Constraint(validatedBy = ...) liên kết annotation với class xử lý logic. ConstraintValidator<A, T> nhận A là annotation, T là kiểu dữ liệu field áp dụng. Method isValid trả true nếu hợp lệ. Lưu ý: nên để isValid trả true khi value == null, vì việc bắt buộc không-null nên giao cho @NotNull riêng – tránh một field bị báo lỗi hai lần với hai message khác nhau.

Dùng trong DTO:

public record OrderLookupRequest(
        @NotBlank
        @ValidOrderCode
        String orderCode
) {}

8. Test validation với JUnit 5 + AssertJ

Bạn có thể test trực tiếp Validator mà không cần khởi động context Spring MVC, nhanh hơn integration test:

class CreateOrderRequestValidationTest {

    private static Validator validator;

    @BeforeAll
    static void setUp() {
        try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
            validator = factory.getValidator();
        }
    }

    @Test
    void shouldRejectBlankCustomerName() {
        CreateOrderRequest request = new CreateOrderRequest(
                "", "a@b.com", 2, BigDecimal.TEN);

        Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);

        assertThat(violations)
                .extracting(v -> v.getPropertyPath().toString())
                .contains("customerName");
    }

    @Test
    void shouldPassWhenAllFieldsValid() {
        CreateOrderRequest request = new CreateOrderRequest(
                "Nguyen Van A", "a@b.com", 2, BigDecimal.TEN);

        Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);

        assertThat(violations).isEmpty();
    }
}

Validation.buildDefaultValidatorFactory() tạo Validator độc lập, không cần Spring context. validator.validate(request) trả về Set<ConstraintViolation> – rỗng nghĩa là hợp lệ. Cách test này phù hợp để unit test riêng DTO, tách biệt khỏi logic controller.

9. Validate path variable và query param

@Valid trên @RequestBody là phổ biến nhất, nhưng bạn cũng validate được param đơn lẻ bằng cách thêm @Validated ở class controller:

@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderQueryController {

    @GetMapping("/{id}")
    public ResponseEntity<String> getOrder(
            @PathVariable @Positive(message = "ID phải lớn hơn 0") Long id) {
        return ResponseEntity.ok("Order " + id);
    }
}

@Validated (của Spring, khác @Valid của Bean Validation) bật validation ở mức method parameter. Khi id = -1, Spring ném ConstraintViolationException (khác với MethodArgumentNotValidException ở mục 5) – bạn cần @ExceptionHandler riêng cho exception này:

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolation(ConstraintViolationException ex) {
    return ResponseEntity.badRequest().body(ex.getMessage());
}

Lưu ý: Hai exception này dễ gây nhầm – MethodArgumentNotValidException dành cho @Valid @RequestBody/@ModelAttribute, còn ConstraintViolationException dành cho @Validated trên path variable/query param. Thiếu một trong hai handler, lỗi sẽ rơi về response 500 mặc định rất xấu.

10. Lỗi hay gặp và khi nào cân nhắc khác

  • Quên @Valid: annotation constraint không tự kích hoạt, bạn phải khai báo @Valid ở đúng vị trí (param controller, field nested object, type argument của generic).
  • Thiếu dependency starter-validation: constraint annotation bị bỏ qua lặng lẽ, không có exception nào ném ra – rất khó phát hiện nếu không viết test.
  • Validate logic phức tạp xuyên nhiều field (ví dụ “ngày kết thúc phải sau ngày bắt đầu”): Bean Validation vẫn làm được qua class-level constraint, nhưng nếu logic phụ thuộc dữ liệu từ database (ví dụ “email chưa tồn tại”), nên xử lý ở service layer thay vì validator – vì validator không nên gọi repository, sẽ làm nó khó test và vi phạm nguyên tắc đơn nhiệm.
  • Message lỗi đa ngôn ngữ: dùng message = "{order.code.invalid}" và file messages.properties thay vì hard-code tiếng Việt trong annotation, nếu dự án cần i18n.

11. Kết luận

Bean Validation giúp bạn khai báo rule kiểm tra dữ liệu ngay trên DTO bằng annotation, thay cho code if-else rải rác. Kết hợp @Valid/@Validated với @RestControllerAdvice, bạn có một luồng validate – báo lỗi nhất quán cho toàn bộ API. Khi constraint có sẵn không đủ, custom validator cho phép mở rộng linh hoạt mà vẫn giữ được phong cách khai báo, dễ đọc, dễ test.

spring-bootvalidationbean-validationjavarest-api