Spring REST API & Data JPA
17/46
Bài 17 / 46~12 phútError, Validation & API DocsLộ trình · chặng 15/21Miễn phí… lượt xem

Validation Nâng Cao: Groups, Custom Constraint và i18n

Làm chủ kỹ thuật validation nâng cao trong Spring MVC: Validation Groups phân tách ngữ cảnh, tạo Custom Constraint, đa ngôn ngữ hóa i18n và service validation.

TL;DR: Khi 23 ràng buộc tích hợp của Jakarta Bean Validation không đáp ứng đủ logic nghiệp vụ phức tạp, Spring MVC cung cấp 4 giải pháp nâng cao: (1) Validation Groups sử dụng các marker interface (Create, Update) kết hợp @Validated để kích hoạt các tập quy tắc khác nhau trên cùng một DTO tùy theo HTTP verb; (2) Custom Constraint tự tạo annotation @Constraint kết hợp ConstraintValidator để chuẩn hóa các quy tắc đặc thù của doanh nghiệp; (3) i18n Error Messages tự động dịch thông điệp lỗi dựa trên header Accept-Language thông qua messages.properties; (4) Service-layer Validation kích hoạt AOP Proxy qua @Validated để bảo vệ các cổng vào không qua HTTP (Kafka, Scheduled Jobs, RPC).

1. Tại sao cần Validation Groups khi tái sử dụng DTO qua nhiều ngữ cảnh?

Trong các thao tác CRUD, yêu cầu kiểm thực giữa hành động tạo mới (POST) và cập nhật (PUT) thường có sự khác biệt:

  • POST (Tạo mới): Thuộc tính id bắt buộc phải là null (server tự sinh), nhưng totalAmount bắt buộc phải có.
  • PUT (Cập nhật): Thuộc tính id bắt buộc phải có, trong khi totalAmount có thể là tùy chọn (chỉ cập nhật trường được gửi lên).

Validation Groups sử dụng các marker interface để đánh dấu ngữ cảnh:

Java
// Dinh nghia cac marker interface
public interface OnCreate {}
public interface OnUpdate {}

public record OrderRequest(
    @Null(groups = OnCreate.class, message = "ID phai la null khi tao moi")
    @NotNull(groups = OnUpdate.class, message = "ID la bat buoc khi cap nhat")
    Long id,

    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    String customer,

    @NotNull(groups = OnCreate.class)
    @Positive(groups = {OnCreate.class, OnUpdate.class})
    BigDecimal total
) {}

Tại Controller, ta sử dụng @Validated(Group.class) thay vì @Valid:

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

    @PostMapping
    public OrderDto createOrder(@Validated(OnCreate.class) @RequestBody OrderRequest req) {
        return orderService.create(req);
    }

    @PutMapping("/{id}")
    public OrderDto updateOrder(
        @PathVariable Long id,
        @Validated(OnUpdate.class) @RequestBody OrderRequest req
    ) {
        return orderService.update(id, req);
    }
}

Một record OrderRequest có ba constraint rẽ thành hai nhánh: nhánh @Valid chạy nhóm Default nên chỉ chạy @Positive total và bỏ qua hai constraint khai groups, nhánh @Validated Create chạy @Null id và @NotBlank customer nhưng bỏ qua @Positive total nên total âm lọt qua

Thực tế thiết kế: Ưu tiên phân tách DTO

Trong 90% trường hợp, việc tạo hai DTO riêng biệt (CreateOrderRequest và UpdateOrderRequest) tốt hơn Validation Groups. Tách DTO giúp sinh OpenAPI schema rõ ràng, code dễ đọc hơn và không gây nhầm lẫn về nhóm Default. Chỉ nên dùng Groups khi số lượng trạng thái nghiệp vụ quá nhiều (Draft, Review, Publish, Archive...).

2. Khi nào nên dùng Custom Constraint và cách tự xây dựng ra sao?

Khi cần kiểm tra quy tắc cú pháp đặc thù của doanh nghiệp (ví dụ định dạng mã SKU ABC-1234), tạo custom constraint giúp tái sử dụng quy tắc trên nhiều DTO.

Bước 1: Khai báo Annotation Constraint

Java
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuValidator.class)
@Documented
public @interface ValidSku {
    String message() default "Ma SKU khong hop le (dinh dang chuan: ABC-1234)";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Bước 2: Cài đặt ConstraintValidator

Java
public class SkuValidator implements ConstraintValidator<ValidSku, String> {
    private static final Pattern SKU_PATTERN = Pattern.compile("^[A-Z]{3}-\\d{4}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // Luon tra ve true neu null de @NotNull dam nhan viec kiem tra nullability
        if (value == null) {
            return true;
        }
        return SKU_PATTERN.matcher(value).matches();
    }
}

Bước 3: Ràng buộc liên trường (Cross-field Validation)

Khi quy tắc cần đối chiếu nhiều trường (ví dụ ngày kết thúc phải sau ngày bắt đầu), đặt annotation ở cấp độ ElementType.TYPE:

Java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
    String message() default "Ngay bat dau phai truoc ngay ket thuc";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class DateRangeValidator implements ConstraintValidator<ValidDateRange, OrderRequest> {
    @Override
    public boolean isValid(OrderRequest req, ConstraintValidatorContext ctx) {
        if (req.orderDate() == null || req.deliveryDate() == null) {
            return true;
        }
        return req.deliveryDate().isAfter(req.orderDate());
    }
}

3. Đa ngôn ngữ hóa (i18n) thông báo lỗi validation vận hành như thế nào?

Thay vì hardcode chuỗi thông báo tiếng Việt hoặc tiếng Anh trong mã nguồn, ta sử dụng message keys được bao bọc bởi cặp ngoặc nhọn {...}:

Java
public record OrderRequest(
    @NotBlank(message = "{order.customer.required}")
    @Size(min = 2, max = 50, message = "{order.customer.size}")
    String customer,

    @NotNull(message = "{order.total.required}")
    @Positive(message = "{order.total.positive}")
    BigDecimal total
) {}

Hệ thống định nghĩa các file tài nguyên trong src/main/resources/:

Properties
# messages.properties (Tieng Viet mac dinh)
order.customer.required=Ten khach hang khong duoc de trong
order.customer.size=Ten khach hang phai tu {min} den {max} ky tu
order.total.required=Tong tien la bat buoc
order.total.positive=Tong tien phai lon hon 0
Properties
# messages_en.properties (Tieng Anh)
order.customer.required=Customer name is required
order.customer.size=Customer name must be between {min} and {max} characters
order.total.required=Total amount is required
order.total.positive=Total amount must be positive

Spring MVC tự động nhận diện ngôn ngữ dựa trên header Accept-Language của client (thông qua AcceptHeaderLocaleResolver), đồng thời tự động điền các placeholder {min}, {max} từ giá trị khai báo trên annotation.

4. Tại sao cần kiểm thực tại tầng Service và cơ chế AOP phía sau là gì?

Kiểm thực tại controller chỉ bảo vệ được các luồng vào từ HTTP. Nếu service được gọi từ Kafka Listener, Cron Job hoặc RPC nội bộ, dữ liệu không hợp lệ có thể lọt vào database nếu không có lớp bảo vệ tại tầng Service.

Bốn hộp nét đứt bên trái là bốn đường gọi vào service gồm HTTP controller có @Valid, Kafka consumer, job định kỳ import CSV và service khác gọi nội bộ, cả bốn cùng đi vào một hộp cao là AOP proxy quanh OrderService, từ đó rẽ thành nhánh ném ConstraintViolationException và nhánh method chạy với dữ liệu sạch

Kích hoạt Validation tại Service với @Validated

Java
@Service
@Validated // Kich hoat Spring AOP MethodValidationInterceptor
public class OrderService {

    public OrderDto createOrder(@Valid OrderRequest req) {
        // Tu dong kiem thuc tham so, nem ConstraintViolationException neu sai
        return orderRepository.save(req.toEntity());
    }

    public OrderDto findById(@NotNull @Positive Long id) {
        return orderRepository.findById(id).orElseThrow();
    }
}

Tại tầng @RestControllerAdvice, ta bổ sung handler để đón bắt ngoại lệ từ proxy AOP:

Java
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    pd.setTitle("Validation failed");
    pd.setDetail("Mot hoac nhieu tham so khong vuot qua kiem thuc");

    List<Map<String, String>> violations = ex.getConstraintViolations().stream()
        .map(v -> Map.of("path", v.getPropertyPath().toString(), "message", v.getMessage()))
        .toList();
    pd.setProperty("violations", violations);
    return pd;
}

5. Những cạm bẫy thiết kế nghiêm trọng cần tránh trong validation nâng cao là gì?

Cạm bẫy 1: Inject Repository vào ConstraintValidator

Nhiều lập trình viên cố gắng inject UserRepository vào ConstraintValidator để kiểm tra "Email đã tồn tại chưa". Đây là phản mẫu (anti-pattern) vì:

  • Phá vỡ nguyên lý đơn nhiệm: Validator chỉ nên kiểm tra tính hợp lệ cú pháp (format, syntax), không nên thực hiện I/O cơ sở dữ liệu.
  • Rủi ro vòng đời: Validator có thể được khởi tạo ngoài ngữ cảnh Spring (ví dụ Unit Test) dẫn đến NullPointerException.
  • Giải pháp đúng: Kiểm tra tính duy nhất hoặc logic tồn tại bên trong tầng Service và ném domain exception (DuplicateEmailException).

Cạm bẫy 2: Tự gọi method trong cùng một class (Self-invocation)

Vì kiểm thực tầng Service hoạt động dựa trên Spring AOP Proxy, nếu method A gọi method B trong cùng một class this.findById(id), lời gọi sẽ không đi qua proxy và các ràng buộc validation trên method B sẽ bị bỏ qua hoàn toàn.

6. Các kỹ thuật validation nâng cao liên kết với các thành phần khác ra sao?

  • Validation Cơ Bản: Đặt nền móng với 23 ràng buộc cơ sở và cơ chế kích hoạt bằng @Valid.
  • OpenAPI & springdoc: Các annotation ràng buộc được thư viện đọc tự động để sinh tài liệu tương tác với các thuộc tính giới hạn minimum, maximum, pattern.

7. Những điểm then chốt cần ghi nhớ là gì?

  • Validation Groups phân định quy tắc theo ngữ cảnh thông qua marker interface và @Validated(Group.class).
  • Ưu tiên phân tách DTO chuyên biệt thay vì lạm dụng Validation Groups khi cấu trúc dữ liệu khác nhau.
  • Custom Constraint bao gồm Annotation và ConstraintValidator; validator phải là pure function không phụ thuộc cơ sở dữ liệu.
  • Đa ngôn ngữ hóa thông báo lỗi qua messages.properties kết hợp Accept-Language header.
  • Tầng Service sử dụng @Validated để bảo vệ các điểm vào phi HTTP (Kafka, cron jobs), ném ra ConstraintViolationException.

8. Bài tập tự kiểm tra mức độ hiểu sâu

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao trong hầu hết trường hợp CRUD đơn giản, việc phân tách thành CreateOrderRequest và UpdateOrderRequest lại được khuyến nghị hơn dùng Validation Groups?
  2. Q2
    Tại sao trong phương thức isValid() của một Custom Constraint, ta luôn nên trả về true khi giá trị đầu vào là null?
  3. Q3
    Tại sao không nên inject @Repository hoặc gọi cơ sở dữ liệu bên trong một ConstraintValidator?
  4. Q4
    Khi kích hoạt validation tại tầng Service bằng cách gắn @Validated lên class Service, ngoại lệ nào sẽ được ném ra khi vi phạm ràng buộc tham số?
  5. Q5
    Cơ chế i18n của Hibernate Validator thay thế các placeholder như {min} và {max} trong file messages.properties dựa trên nguồn dữ liệu nào?

Bài tiếp theo: OpenAPI & springdoc

Bài này có giúp bạn hiểu bản chất không?

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.

Lộ trình Junior Java Backend Developer

Chặng 15/21 · REST API với Spring MVC — bài 17/21 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

OpenAPI và springdoc: Tự Động Sinh Tài Liệu REST API