Spring
Trung cấp11 phút đọcbài 3/3

@Valid / @Validated trong Spring

SpringSpring BootJava

@Valid: là annotation của chuẩn JSR303; @Validated là một annotation của Spring - bật validate cho bean/tham số, chỉ định group muốn dùng. Chúng không thay thế cho nhau nhé

Câu hỏi "khi nào dùng @Valid, khi nào dùng @Validated" gần như không thể trả lời trực tiếp, vì nó đặt sai vấn đề. Câu hỏi đúng là: "ai đang đọc annotation này?"

Phần I - Nền tảng

1.1 Bean Validation: đặc tả, không phải Spring

Bean Validation là một đặc tả Java (JSR 303 → JSR 349 → JSR 380 → nay là Jakarta Validation 3.x). Spring không cài đặt đặc tả này; Spring chỉ tích hợp một implementation — mặc định trong Spring Boot là Hibernate Validator.

Đặc tả định nghĩa ba API cốt lõi mà phần còn lại của bài viết sẽ liên tục nhắc tới:

APIMục đích
Validator#validate(T object, Class<?>... groups)Validate trạng thái của một object (các field, getter)
ExecutableValidator#validateParameters(T obj, Method m, Object[] args, Class<?>... groups)Validate tham số của một lời gọi method
ConstraintViolation<T>Kết quả trả về: propertyPath, message, invalidValue...

Các constraint annotation (@NotNull, @Size, @Email...) hoàn toàn thụ động. Chúng chỉ là metadata nằm im trong bytecode. Không có ai gọi Validator#validate() thì chúng không làm gì cả — ứng dụng vẫn compile, vẫn chạy, vẫn deploy production bình thường với dữ liệu rác. @Valid@Validated chính là hai loại "tín hiệu" khác nhau để kích hoạt lời gọi đó.

1.2. Deep dive into @Valid - xem source nó có gì ?

Xem source nguyên gốc của @Valid nhé

package javax.validation;

import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

/**
 * Marks a property, method parameter or method return type for validation cascading.
 * <p>
 * Constraints defined on the object and its properties are be validated when the
 * property, method parameter or method return type is validated.
 * <p>
 * This behavior is applied recursively.

@Target({ METHOD, FIELD, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface Valid {
}

Bốn điều rút ra trực tiếp từ source code:

  • Đệ quy — cascade không dừng ở một tầng. Order → @Valid List<OrderItem> → mỗi OrderItem có @Valid Product → engine đi tiếp xuống Product. Độ sâu là không giới hạn.

  • Không có thuộc tính nào. Một annotation không có phần tử thì không thể truyền tham số. Vì vậy @Valid không thể mang validation group. Đây là lý do kỹ thuật duy nhất khiến Spring phải tạo ra @Validated.

  • TYPE_USE (không phải TYPE) cho phép viết List<@Valid OrderItemDTO> items — cho phép annotation bám vào mọi nơi một kiểu được sử dụng cascade vào từng phần tử của collection. Đây là một tính năng của Bean Validation 2.0+, không phải của Spring.

    private List<@Valid OrderItemDTO> items;        // annotation   nằm bên trong generic
    private Map<String, @Valid AddressDTO> addrs;   // chỉ cascade vào value
    private Optional<@Valid CouponDTO> coupon;      // cascade qua container Optional
    

    Mặt trái của TYPE_USE: theo JLS, nó cũng cho phép annotation bám vào khai báo kiểu. Nên đoạn này compile sạch mà không một warning nào:

    @Valid                    //  compile OK, nhưng engine không bao giờ đọc tới
    public class Retry { }
    

    Compiler không sai — TYPE_USE cho phép về mặt cú pháp. Nhưng phần javadoc đã nói rõ: cascade được đánh dấu trên property/parameter/return type, tức là trên chỗ tham chiếu tới object, không phải trên định nghĩa của object. Lỗi này rất khó phát hiện - trong lúc đọc source code của 1 public repo lớn bên TQ - nhiều senior cũng không để ý đến mấy cái tiểu tiết như thế này nhưng nó rất đáng để học đúng không ^~^.

  • Nó thuộc package jakarta.validationđây là chuẩn, không phụ thuộc Spring. Cùng một @Valid sẽ chạy được trên Quarkus, Micronaut, hay Jakarta EE thuần. Ngữ nghĩa của @Validcascade (đệ quy): "khi validate object chứa tôi, hãy đi tiếp xuống object mà tôi đang trỏ tới". Nó là một chỉ dẫn cho engine, không phải một công tắc bật engine.

1.3. Deep dive @Validated - xem source nó có gì?


package org.springframework.validation.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Variant of JSR-303's {@link javax.validation.Valid}, supporting the
 * specification of validation groups. Designed for convenient use with
 * Spring's JSR-303 support but not JSR-303 specific.
 *
 * <p>Can be used e.g. with Spring MVC handler methods arguments.
 * Supported through {@link org.springframework.validation.SmartValidator}'s
 * validation hint concept, with validation group classes acting as hint objects.
 *
 * <p>Can also be used with method level validation, indicating that a specific
 * class is supposed to be validated at the method level (acting as a pointcut
 * for the corresponding validation interceptor), but also optionally specifying
 * the validation groups for method-level validation in the annotated class.
 * Applying this annotation at the method level allows for overriding the
 * validation groups for a specific method but does not serve as a pointcut;
 * a class-level annotation is nevertheless necessary to trigger method validation
 * for a specific bean to begin with. Can also be used as a meta-annotation on a
 * custom stereotype annotation or a custom group-specific validated annotation.
 *
 * @author Juergen Hoeller
 * @since 3.1
 * @see javax.validation.Validator#validate(Object, Class[])
 * @see org.springframework.validation.SmartValidator#validate(Object, org.springframework.validation.Errors, Object...)
 * @see org.springframework.validation.beanvalidation.SpringValidatorAdapter
 * @see org.springframework.validation.beanvalidation.MethodValidationPostProcessor
 */
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {

	/**
	 * Specify one or more validation groups to apply to the validation step
	 * kicked off by this annotation.
	 * <p>JSR-303 defines validation groups as custom annotations which an application declares
	 * for the sole purpose of using them as type-safe group arguments, as implemented in
	 * {@link org.springframework.validation.beanvalidation.SpringValidatorAdapter}.
	 * <p>Other {@link org.springframework.validation.SmartValidator} implementations may
	 * support class arguments in other ways as well.
	 */
	Class<?>[] value() default {};

}

Dập ngay vào mắt là cả mớ comment - tự nó mô tả gần như toàn bộ cơ chế của @Validated rồi.

  • Câu đầu tiên đã nói thẳng lý do tồn tại

Variant of JSR-303's @Valid, supporting the specification of validation groups.

Tác giả viết rõ ngay dòng đầu: đây là biến thể của @Valid, sinh ra để giải quyết đúng một thiếu sót - không truyền được group. Chú ý đoạn "but not JSR-303 specific" - @Validated được thiết kế để hoạt động với SmartValidator nói chung, không chỉ là Bean Validation.

  • Có tham số -> truyền được group
Class<?>[] value() default {};

Đúng một phần tử, và default {} nghĩa là mặc định rỗng — khi rỗng thì hành vi trùng khít với @Valid (chạy group Default). Đây là toàn bộ khác biệt về mặt "dữ liệu" giữa hai annotation.

  • ElementType.TYPE(@Valid không có) -> đây là pointcut marker cho AOP
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})

So sánh với @Valid:

Vị trí@Valid@Validated
Class declarationTYPE_USE (compile được nhưng vô nghĩa)TYPE — đây là mục đích chính
Method✅ (validate return value)✅ (override group)
Parameter
Fieldkhông có
Bên trong generic✅ (TYPE_USE)

@Validated KHÔNG có FIELD — nên nó không thể thay @Valid để cascade xuống field lồng nhau. Ngữ nghĩa của @Validated là kích hoạt + cấu hình group: nó nói với hạ tầng Spring "hãy chạy validation ở đây, với các group này". Nó thuộc package org.springframework.validation.annotation

II - Use case sử dụng

Đọc lí thuyết chán rồi, giồ thực hành thôi nhỉ

2.1. @Validated trên @ConfigurationPerperties: fail-fast lúc khởi động

@ConfigurationProperties(prefix = "shop.payment-gateway")
@Validated
@Data
public class PaymentGatewayProperties {

    @NotEmpty(message = "API key không được để trống")
    private String apiKey;

    @NotNull(message = "Timeout không được để trống")
    @Positive(message = "Timeout phải lớn hơn 0")
    private Integer timeoutMs;

    @Valid                        // ← bắt buộc, nếu không Retry sẽ không được validate
    private Retry retry = new Retry();

    @Data
    public static class Retry {
        @Min(0) @Max(5)
        private int maxAttempts = 3;
    }
}

Nếu muốn hiểu rõ cách hoạt động thì đọc class ConfigurationPropertiesBinder

Dùng khi nào?

Mọi class @ConfigurationProperties đọc giá trị từ nguồn bên ngoài (application.yaml, env var, Config Server, Vault). Đây là nơi bạn muốn hệ thống fail fast — sai cấu hình thì không được phép start.

2.2. Kết hợp @Valid và @RequestBody

Cái này thì không xa lạ gì rồi — chuyên dùng validate request body. Nhưng có một chi tiết dễ gây hiểu nhầm: @Valid ở đây hoạt động hoàn hảo dù controller không hề có @Validated. Nhiều người từ đó kết luận rằng @Valid "đủ mạnh để tự chạy". Sự thật ngược lại.

Với mỗi tham số @RequestBody, Spring MVC giao việc cho một thành phần chuyên trách: nó dùng HttpMessageConverter đọc JSON và dựng ra DTO, rồi luôn luôn thực hiện thêm một bước — soi các annotation nằm trên chính tham số đó xem có @Valid hay @Validated không. Thấy có thì gọi validator, không thấy thì đi tiếp.

Nói cách khác, @Valid chạy được không phải vì bản thân nó mạnh, mà vì đã có sẵn một người đứng đó chờ đọc nó. Bước kiểm tra này được cài cứng vào pipeline xử lý @RequestBody, không cần ai bật.

Ba hệ quả rút ra:

  • @Validated trên class controller là thừa với cơ chế này. Bước soi annotation nhìn vào tham số, không nhìn vào class.
  • @Validated đặt trên tham số thì lại được đọc bình thường, ngang hàng với @Valid. Nó không bật thêm gì cả — khác biệt duy nhất là mang theo được validation group, thứ mà @Valid không có.
  • Bỏ @RequestBody đi — đổi sang @RequestParam, hoặc gọi thẳng orderService.create(dto) từ một job nền — là không còn ai đứng đọc nữa, @Valid lập tức thành vô dụng. Nó gắn chết vào pipeline HTTP, không phải cơ chế validate chung cho mọi lời gọi hàm.

2.3. @Validated trên bean: AOP proxy chặn mọi lời gọn method

public interface OrderService {
    OrderRespDTO create(@Valid OrderCreateReqDTO reqDTO);    // ← @Valid ĐẶT Ở ĐÂY
}

@Service
@Validated                                                    // ← @Validated ĐẶT Ở ĐÂY
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    @Override
    public OrderRespDTO create(OrderCreateReqDTO reqDTO) {
        // logic tạo đơn hàng...
        return new OrderRespDTO();
    }
}

Đây là cơ chế ít được biết nhất, vì nó không nằm ở controller cũng không nằm ở config. Nó validate bất kỳ lời gọi method nào đi qua proxy — không cần có HTTP request nào liên quan.

@Valid phải đặt ở interface hay impl?

Câu trả lời an toàn là: khai báo constraint ở interface (hoặc ở class nếu không có interface), và giữ nhất quán. Hai lý do độc lập:

  1. Đặc tả Bean Validation cấm "siết chặt" tiền điều kiện ở subtype. Nếu interface không có constraint mà impl thêm @Valid/@NotNull vào tham số, Hibernate Validator ném ConstraintDeclarationException — theo nguyên lý Liskov: một client cầm reference kiểu OrderService không thể biết implementation nào đó lại đòi hỏi khắt khe hơn hợp đồng.
  2. Method nào được validate phụ thuộc loại proxy. MethodValidationInterceptor gọi execVal.validateParameters(target, invocation.getMethod(), args, groups). Với JDK proxy, invocation.getMethod() là method của interface; với CGLIB là method của class. Constraint khai báo trên interface được Hibernate Validator gom vào metadata của cả cấp bậc kế thừa nên chạy đúng trong cả hai trường hợp — constraint chỉ nằm ở impl thì không.

Nói ngắn gọn: interface là hợp đồng, và constraint là một phần của hợp đồng. (nhớ ở class impl phải đặt @Validated)

III - Validation groups: vai trò chính thức của @Validated

Đây là công dụng ai cũng học đầu tiên, và cũng là lý do kỹ thuật khiến @Validated phải tồn tại: @Valid không có value() nên vĩnh viễn không mang được group.

Kịch bản kinh điển: cùng một DTO dùng cho cả tạo mới và cập nhật, nhưng id phải null khi tạo và bắt buộc khi cập nhật.

// Marker interface — không có method, chỉ để làm "nhãn"
public interface OnCreate {}
public interface OnUpdate {}

@Data
public class OrderDTO {

    @Null(groups = OnCreate.class,    message = "id phải để trống khi tạo mới")
    @NotNull(groups = OnUpdate.class, message = "id là bắt buộc khi cập nhật")
    private Long id;

    @NotEmpty(groups = {OnCreate.class, OnUpdate.class})
    private List<@Valid OrderItemDTO> items;

    @NotNull    // không khai báo group → thuộc group Default
    private Long shippingAddressId;
}

Áp dụng ở tầng controller:

@PostMapping
public OrderRespDTO create(@Validated(OnCreate.class) @RequestBody OrderDTO dto) { ... }

@PutMapping("/{id}")
public OrderRespDTO update(@Validated(OnUpdate.class) @RequestBody OrderDTO dto) { ... }

Hết. Hầu hết mọi người chỉ biết dùng @Valid ở @RestController kết hợp với @RequestBody nhưng mà nó có nhiều usecase đáng dùng như đã liệt kê ở trên, cái này phải thực chiến và nên nhảy thẳng vào source Ctrl + Clikc vào hàm của nó mà xem thì sẽ hiểu hơn.