Kiểm dữ liệu đầu vào là việc phải làm ở mọi API. Spring có cơ chế khai báo cho việc đó.

Bật

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

Từ Boot 2.3, nó không còn nằm trong starter-web — phải thêm tường minh. Đây là lý do nhiều người nâng cấp rồi thấy validation im lặng ngừng hoạt động.

Khai ràng buộc

record Don(
        @NotBlank String ma,
        @Min(1) int soLuong,
        @Email String email) {}

@PostMapping("/don")
ResponseEntity<Ket> tao(@Valid @RequestBody Don d) { ... }

@Valid là thứ kích hoạt kiểm tra. Thiếu nó thì mọi ràng buộc bị bỏ qua, im lặng — lỗi phổ biến nhất với validation.

Kết quả khi gửi dữ liệu sai cả ba trường:

  {"thongDiep":"dữ liệu không hợp lệ",
   "chiTiet":["email: must be a well-formed email address",
              "ma: must not be blank",
              "soLuong: must be greater than or equal to 1"]}

Cả ba lỗi cùng lúc, không dừng ở cái đầu tiên. Đây là hành vi đúng cho API: người dùng sửa một lần thay vì gửi lại ba lần.

Các ràng buộc hay dùng

@NotNull            // khác null
@NotEmpty           // khác null và không rỗng (chuỗi, collection)
@NotBlank           // khác null và có ít nhất một ký tự không phải khoảng trắng
@Size(min=, max=)
@Min / @Max         // số
@Positive / @Negative
@Email
@Pattern(regexp = "...")
@Past / @Future     // ngày giờ

Phân biệt ba cái đầu là chỗ hay nhầm: @NotEmpty cho " "hợp lệ; @NotBlank thì không. Với dữ liệu người dùng nhập, gần như luôn muốn @NotBlank.

Lồng nhau

record Don(@NotBlank String ma, @Valid @NotNull DiaChi diaChi) {}
record DiaChi(@NotBlank String duong, @NotBlank String thanhPho) {}

@Valid trên trường là bắt buộc để kiểm sâu vào đối tượng con. Thiếu nó, DiaChi được kiểm là khác null nhưng bên trong không ai xem.

Với collection: List<@Valid Muc> danhSach.

Thông báo tiếng Việt

Mặc định thông báo là tiếng Anh. Ba cách đổi:

Ghi thẳng:

@NotBlank(message = "mã đơn không được để trống")

Dùng tệp messages:

@NotBlank(message = "{don.ma.trong}")
# messages_vi.properties
don.ma.trong=mã đơn không được để trống

Đặt ngôn ngữ mặc định:

spring:
  messages:
    basename: messages
    encoding: UTF-8

Nhớ encoding: UTF-8, nếu không tiếng Việt ra dấu hỏi.

@Valid khác @Validated

@Valid là chuẩn Jakarta, dùng trên tham số method của controller.

@Validated là của Spring, và nó làm thêm hai việc:

Kiểm ở mức lớp — đặt trên @Service để kiểm tham số của mọi method:

@Service
@Validated
class DichVu {
    void luu(@NotBlank String ma) { }    // ném ConstraintViolationException
}

Chú ý ngoại lệ ở đây là ConstraintViolationException, khác với MethodArgumentNotValidException của controller — nên bạn cần hai handler.

Nhóm ràng buộc:

interface Tao {}
interface Sua {}

record Don(@Null(groups = Tao.class) @NotNull(groups = Sua.class) Long id, ...) {}

@PostMapping void tao(@Validated(Tao.class) @RequestBody Don d) { }
@PutMapping  void sua(@Validated(Sua.class) @RequestBody Don d) { }

Hữu ích khi cùng một DTO dùng cho tạo và sửa với ràng buộc khác nhau.

Ràng buộc tự viết

@Target(FIELD) @Retention(RUNTIME)
@Constraint(validatedBy = MaDonValidator.class)
@interface MaDon {
    String message() default "mã đơn phải có dạng DH-xxxx";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class MaDonValidator implements ConstraintValidator<MaDon, String> {
    public boolean isValid(String v, ConstraintValidatorContext c) {
        return v == null || v.matches("DH-\\d{4}");
    }
}

Ba method message, groups, payload là bắt buộc theo đặc tả.

Chú ý v == null trả true: quy ước là mỗi ràng buộc chỉ lo một việc, và null do @NotNull lo. Kết hợp @NotNull @MaDon khi cần cả hai.

Validator là bean nên tiêm được phụ thuộc — kiểm mã có tồn tại trong CSDL chẳng hạn. Nhưng cân nhắc: validation nên nhanh và không có tác dụng phụ; kiểm nghiệp vụ phức tạp thuộc về service.

Validation không thay được kiểm nghiệp vụ

Bean Validation kiểm hình dạng dữ liệu: có mặt, đúng định dạng, trong khoảng.

không kiểm được: mã đơn có tồn tại không, người dùng có quyền không, số dư có đủ không. Những thứ đó cần dữ liệu khác và thuộc về tầng nghiệp vụ.

Ranh giới thực dụng: validation trả 400, nghiệp vụ trả 409 hoặc 422.

Thử ba mươi giây

grep -rn '@RequestBody' --include='*.java' src/main | grep -v '@Valid'

Mỗi kết quả là một endpoint nhận dữ liệu không được kiểm gì cả — kể cả khi DTO của nó đầy chú thích ràng buộc. Thêm @Valid là sửa xong.

Ngày mai: xử lý ngoại lệ toàn cục — và cái bẫy bắt Exception.