Command Palette

Search for a command to run...

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

API sản phẩm từ các bài trước đọc JSON body vào record CreateProductRequest rồi lưu lại. Trên đường đi đó không có bước nào kiểm tra dữ liệu. Client có thể gửi tên để trống, giá bằng 0, tồn kho -3 hay SKU sai định dạng; Jackson vẫn tạo record, controller vẫn lưu, và API vẫn trả 201 Created. Dữ liệu sai chỉ lộ ra về sau, trong một báo cáo lệch số hoặc một đơn hàng không giao được.

Bean Validation, chuẩn Jakarta Validation mà Hibernate Validator hiện thực, đưa các quy tắc đó lên DTO dưới dạng annotation, và Spring MVC áp dụng chúng trước khi method của controller chạy. Bài này thêm validation vào API sản phẩm và dành phần lớn thời lượng cho những chỗ thường bị lướt qua: mỗi constraint có sẵn thực sự chấp nhận giá trị nào, response 400 mặc định chứa gì và chi tiết lỗi đi đâu, khi nào object lồng nhau bị bỏ qua mà không báo gì, vì sao một constraint trên @PathVariable có thể kết thúc bằng 500, và group, message, custom constraint cùng validation ngoài web layer hoạt động ra sao.

JSON body đi qua cổng @Valid vào CreateProductRequest, một field không hợp lệ và request bị trả về 400

Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, Hibernate Validator 9.1.3.Final, Jakarta Validation 3.1.1, Tomcat 11.0.24) và Gradle 9.7.1, trên project sinh bởi Spring Initializr với dependencies=web,validation. Application chạy với --server.port=8119, nên các request bên dưới gửi tới port đó. Mọi response, dòng log và thông báo lỗi đều copy từ các lần chạy đó; các dòng log đã được cắt bỏ phần timestamp ở đầu.

Thêm spring-boot-starter-validation

Validation không nằm trong web starter. Nó đến từ spring-boot-starter-validation, dependency mà Spring Initializr gọi là validation, và project sinh ra cùng nó còn có thêm một test starter tương ứng:

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-webmvc'
	testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
	testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Chạy ./gradlew dependencies --configuration runtimeClasspath và chỉ giữ lại nhánh của starter:

Text
+--- org.springframework.boot:spring-boot-starter-validation -> 4.1.1
|    +--- org.springframework.boot:spring-boot-starter:4.1.1
|    \--- org.springframework.boot:spring-boot-validation:4.1.1
|         +--- org.springframework.boot:spring-boot:4.1.1 (*)
|         +--- org.apache.tomcat.embed:tomcat-embed-el:11.0.24
|         \--- org.hibernate.validator:hibernate-validator:9.1.3.Final
|              +--- jakarta.validation:jakarta.validation-api:3.1.1
|              +--- org.jboss.logging:jboss-logging:3.6.3.Final
|              \--- com.fasterxml:classmate:1.7.1 -> 1.7.3
JarCung cấp gì
jakarta.validation-api 3.1.1API chuẩn: @NotNull, @Valid, ConstraintValidator, Validator
hibernate-validator 9.1.3.Finalphần hiện thực thực sự đánh giá các constraint
tomcat-embed-el 11.0.24một hiện thực của Jakarta Expression Language, dùng cho các biểu thức ${…} trong message
spring-boot-validation 4.1.1ValidationAutoConfiguration, nơi định nghĩa hai bean defaultValidatormethodValidationPostProcessor

defaultValidator là một LocalValidatorFactoryBean bọc Hibernate Validator, và trong application này nó là bean jakarta.validation.Validator duy nhất. methodValidationPostProcessor chỉ có ý nghĩa khi @Validated xuất hiện trên một class, ở phần sau của bài.

Validate request body với @Valid

Constraint là các annotation đặt trên component của record request. Phiên bản đầu tiên của CreateProductRequest có bốn quy tắc:

src/main/java/com/example/demo/product/CreateProductRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
 
public record CreateProductRequest(
        @NotBlank String name,
        @NotNull @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String sku,
        @NotNull @DecimalMin("0.01") BigDecimal price,
        @NotNull @PositiveOrZero Integer stock) {
}

@NotBlank từ chối tên bị thiếu hoặc chỉ có khoảng trắng. @NotNull bắt buộc phải có sku, pricestock, @Pattern cố định định dạng SKU, @DecimalMin đặt mức sàn cho giá, còn @PositiveOrZero loại bỏ tồn kho âm. Phần tiếp theo sẽ đi qua từng constraint.

Phần còn lại của API gồm một domain record, một response record và một store trong bộ nhớ, vì database chỉ xuất hiện ở Chương 4:

src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record Product(Long id, String name, String sku, BigDecimal price, Integer stock) {
}
src/main/java/com/example/demo/product/ProductResponse.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
public record ProductResponse(Long id, String name, String sku, BigDecimal price, Integer stock) {
 
    static ProductResponse from(Product product) {
        return new ProductResponse(product.id(), product.name(), product.sku(), product.price(), product.stock());
    }
}
src/main/java/com/example/demo/product/ProductStore.java
package com.example.demo.product;
 
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
 
import org.springframework.stereotype.Component;
 
@Component
public class ProductStore {
 
    private final Map<Long, Product> products = new ConcurrentHashMap<>();
    private final AtomicLong nextId = new AtomicLong(1);
 
    public Product save(CreateProductRequest request) {
        long id = nextId.getAndIncrement();
        Product product = new Product(id, request.name(), request.sku(), request.price(), request.stock());
        products.put(id, product);
        return product;
    }
 
    public Optional<Product> findById(Long id) {
        return Optional.ofNullable(products.get(id));
    }
 
    public List<Product> findAll(int page, int size) {
        return products.values().stream().skip((long) page * size).limit(size).toList();
    }
}

Riêng các annotation thì chưa thay đổi gì cả. Chúng chỉ có tác dụng khi một parameter của controller yêu cầu validation bằng @Valid, thuộc package jakarta.validation:

src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
 
import jakarta.validation.Valid;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("/api/products")
public class ProductController {
 
    private final ProductStore store;
 
    public ProductController(ProductStore store) {
        this.store = store;
    }
 
    @PostMapping
    public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
        Product product = store.save(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(ProductResponse.from(product));
    }
}

Trước hết là một sản phẩm hợp lệ:

Bash
curl -i -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25}'
Text
HTTP/1.1 201 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 06:59:17 GMT
 
{"id":1,"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25}

Sau đó là một body vi phạm cả bốn quy tắc:

Bash
curl -i -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"  ","sku":"kbd-1001","price":0,"stock":-3}'
Text
HTTP/1.1 400 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 06:59:17 GMT
Connection: close
 
{"timestamp":"2026-09-12T06:59:17.579Z","status":400,"error":"Bad Request","path":"/api/products"}

Có ba chuyện đã xảy ra, và client chỉ thấy được một.

  • Request bị từ chối với 400 trước khi method của controller chạy. RequestResponseBodyMethodProcessor, thành phần của Spring MVC chuyên resolve argument @RequestBody, để Jackson tạo record, validate record đó vì parameter có @Valid, rồi ném MethodArgumentNotValidException ngay trong lúc còn đang resolve argument. store.save không bao giờ được gọi.
  • Body là error response chung của Spring Boot. DefaultHandlerExceptionResolver map exception thành 400, và phần xử lý lỗi của Boot ghi ra timestamp, status, errorpath. Không có gì trong đó cho biết field nào sai.
  • Chi tiết nằm trong log. Resolver ghi ra một dòng WARN:
Text
WARN 34552 --- [demo] [nio-8119-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public org.springframework.http.ResponseEntity<com.example.demo.product.ProductResponse> com.example.demo.product.ProductController.create(com.example.demo.product.CreateProductRequest) with 4 errors: [Field error in object 'createProductRequest' on field 'name': rejected value [  ]; codes [NotBlank.createProductRequest.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createProductRequest.name,name]; arguments []; default message [name]]; default message [must not be blank]] [Field error in object 'createProductRequest' on field 'price': rejected value [0]; codes [DecimalMin.createProductRequest.price,DecimalMin.price,DecimalMin.java.math.BigDecimal,DecimalMin]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createProductRequest.price,price]; arguments []; default message [price],true,0.01]; default message [must be greater than or equal to 0.01]] [Field error in object 'createProductRequest' on field 'stock': rejected value [-3]; codes [PositiveOrZero.createProductRequest.stock,PositiveOrZero.stock,PositiveOrZero.java.lang.Integer,PositiveOrZero]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createProductRequest.stock,stock]; arguments []; default message [stock]]; default message [must be greater than or equal to 0]] [Field error in object 'createProductRequest' on field 'sku': rejected value [kbd-1001]; codes [Pattern.createProductRequest.sku,Pattern.sku,Pattern.java.lang.String,Pattern]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [createProductRequest.sku,sku]; arguments []; default message [sku],[Ljakarta.validation.constraints.Pattern$Flag;@53c55407,^[A-Z]{3}-\d{4}$]; default message [must match "^[A-Z]{3}-\d{4}$"]] ]

Nó vẫn ghi log dù spring.mvc.log-resolved-exception mặc định là false: phần mô tả của property này trong metadata 4.1.1 ghi rõ nó áp dụng cho mọi resolver, trừ DefaultHandlerExceptionResolver. Dòng log chứa những gì:

  • with 4 errors: mọi constraint đều được đánh giá. Validation không dừng ở lỗi đầu tiên.
  • Field error in object 'createProductRequest' on field 'name': tên object là tên type của parameter với chữ cái đầu viết thường, và mỗi lỗi nêu tên field của nó.
  • rejected value [ ]default message [must not be blank]: giá trị đã gửi và message của constraint.
  • codes [NotBlank.createProductRequest.name,NotBlank.name,NotBlank.java.lang.String,NotBlank]: các message code, từ cụ thể nhất đến chung nhất, mà một MessageSource của Spring có thể dùng để tra message.
  • Thứ tự: name, price, stock, sku. Các lỗi không đi theo thứ tự khai báo.

Chuyển các field error này thành một response body mà client đọc được là việc của bài tiếp theo; bài đó cũng trả 422 thay vì 400 cho một body không qua được validation, theo thiết kế status code trong bài về HTTP và REST. Trong lúc chờ, những dòng dài như vậy rất khó theo dõi, nên từ đây mỗi dòng WARN sẽ được rút gọn còn field, rejected value và message của từng lỗi. Ví dụ với một JSON object rỗng:

Bash
curl -s -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{}'
Text
{"timestamp":"2026-09-12T06:59:17.590Z","status":400,"error":"Bad Request","path":"/api/products"}
Text
MethodArgumentNotValidException, 4 errors
  stock  [null]  must not be null
  name   [null]  must not be blank
  sku    [null]  must not be null
  price  [null]  must not be null

sku có cả @NotNull lẫn @Pattern, nhưng chỉ @NotNull báo lỗi. Phần về null bên dưới giải thích vì sao.

Chuyện gì xảy ra khi thiếu validation starter?

Tùy vào những gì còn lại trên classpath.

Nếu chỉ có spring-boot-starter-webmvc, project không compile được, vì web starter hoàn toàn không mang theo Jakarta Validation API (đường dẫn đã rút gọn):

Text
src/main/java/com/example/demo/product/ProductController.java:3: error: package jakarta.validation does not exist
import jakarta.validation.Valid;
                         ^

Lỗi tương tự lặp lại cho mọi dòng import jakarta.validation.constraints trong CreateProductRequest.

Trường hợp nguy hiểm là có API mà không có phần hiện thực: jakarta.validation:jakarta.validation-api được khai báo riêng, hoặc do một thư viện nào đó kéo vào. Mọi thứ compile và khởi động bình thường, và request không hợp lệ ở trên được lưu lại:

Text
HTTP/1.1 201 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:00:34 GMT
 
{"id":2,"name":"  ","sku":"kbd-1001","price":0,"stock":-3}

Dấu vết duy nhất là một dòng INFO lúc khởi động:

Text
INFO 34909 --- [demo] [           main] o.s.v.b.OptionalValidatorFactoryBean     : Failed to set up a Bean Validation provider: jakarta.validation.NoProviderFoundException: Unable to create a Configuration, because no Jakarta Validation provider could be found. Add a provider like Hibernate Validator (RI) to your classpath.

Không có provider, @Valid bị bỏ qua mà không báo lỗi gì. @ConfigurationProperties thì chặt hơn: với cùng classpath đó, một properties class có @Validated sẽ chặn application ngay lúc khởi động, như bài về configuration properties đã cho thấy. Khi @Valid có vẻ không làm gì cả, hãy tìm trong log khởi động dòng Failed to set up a Bean Validation provider.

Các constraint annotation có sẵn

Các constraint chuẩn nằm trong jakarta.validation.constraints. Để thử từng loại, record request có thêm ba component và hai constraint nữa (các dòng import cho annotation mới và java.time.LocalDate được lược bỏ):

src/main/java/com/example/demo/product/CreateProductRequest.java
public record CreateProductRequest(
        @NotBlank String name, 
        @NotBlank @Size(min = 3, max = 100) String name, 
        @NotNull @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String sku,
        @NotNull @DecimalMin("0.01") BigDecimal price, 
        @NotNull @DecimalMin("0.01") @Digits(integer = 9, fraction = 2) BigDecimal price, 
        @NotNull @PositiveOrZero Integer stock) { 
        @NotNull @PositiveOrZero Integer stock, 
        @Min(1) @Max(10) Integer maxPerOrder, 
        @PastOrPresent LocalDate releasedOn, 
        @Future LocalDate saleEndsOn) { 
}

Mọi kết quả trong các bảng của phần này đều lấy từ bean Validator của application, kiểm tra từng giá trị một bằng validateValue, method chạy các constraint của một property duy nhất mà không cần tạo object:

Java
Set<ConstraintViolation<CreateProductRequest>> violations =
        validator.validateValue(CreateProductRequest.class, "sku", "kbd-1001");

Những dòng có constraint không nằm trên CreateProductRequest lấy từ các probe record nhỏ khai báo theo cùng cách. Cách inject Validator vào code của bạn được trình bày ở gần cuối bài.

@NotNull vs @NotEmpty vs @NotBlank

Cả ba constraint đều mang nghĩa "bắt buộc", nhưng theo ba cách khác nhau. Một probe record đặt mỗi constraint lên một component:

Java
record Blank(@NotNull String notNull, @NotEmpty String notEmpty, @NotBlank String notBlank) {}
Giá trị@NotNull@NotEmpty@NotBlank
nullmust not be nullmust not be emptymust not be blank
""hợp lệmust not be emptymust not be blank
" "hợp lệhợp lệmust not be blank
một tab và một ký tự xuống dònghợp lệhợp lệmust not be blank
"a"hợp lệhợp lệhợp lệ
  • @NotNull chỉ từ chối null. Chuỗi rỗng và chuỗi toàn khoảng trắng đều qua, nên đặt trên một String thì nó hiếm khi diễn đạt đúng ý bạn.
  • @NotEmpty từ chối thêm "", nhưng chuỗi toàn khoảng trắng vẫn qua.
  • @NotBlank cần ít nhất một ký tự không phải khoảng trắng; tab và xuống dòng đều được tính là khoảng trắng. Đây là lựa chọn đúng cho chữ do người nhập: tên, tiêu đề, mã.

Dùng @NotBlank cho chuỗi và @NotNull cho mọi thứ bắt buộc còn lại: BigDecimal price, Integer stock, một object lồng nhau.

Constraint cho kích thước và số

price mang ba constraint, @NotNull @DecimalMin("0.01") @Digits(integer = 9, fraction = 2):

priceKết quả
1290000hợp lệ
0.01hợp lệ
0.00must be greater than or equal to 0.01
-5must be greater than or equal to 0.01
0.009must be greater than or equal to 0.01numeric value out of bounds (<9 digits>.<2 digits> expected)
1290000.50hợp lệ
1290000.505numeric value out of bounds (<9 digits>.<2 digits> expected)
1290000.500numeric value out of bounds (<9 digits>.<2 digits> expected)
1234567890numeric value out of bounds (<9 digits>.<2 digits> expected)
nullmust not be null

Các constraint về kích thước và số còn lại:

ConstraintGiá trịKết quả
@PositiveOrZero Integer stock0hợp lệ
-1must be greater than or equal to 0
@Positive BigDecimal0must be greater than 0
0.001hợp lệ
@Min(1) @Max(10) Integer maxPerOrder0must be greater than or equal to 1
110hợp lệ
11must be less than or equal to 10
@Min(1) BigDecimal0.99must be greater than or equal to 1
@DecimalMin(value = "0.00", inclusive = false) BigDecimal0.00must be greater than 0.00
0.01hợp lệ
@Size(min = 3, max = 100) String name"ab"size must be between 3 and 100
" x "hợp lệ
@Size(max = 2) List<String>ba phần tửsize must be between 0 and 2

Các bảng cho thấy:

  • @Size đếm ký tự và không trim. " x " có bốn ký tự nên qua được min = 3. Hãy kết hợp với @NotBlank khi khoảng trắng không được tính.
  • @DecimalMin@DecimalMax nhận giới hạn dưới dạng chuỗi và mặc định bao gồm cả chính giới hạn đó. inclusive = false đổi cả cách kiểm tra lẫn message. @Min@Max nhận một long, nên không biểu diễn được 0.01, dù @Min(1) vẫn dùng được trên BigDecimal.
  • @Digits đếm chữ số của BigDecimal đúng như lúc viết, tính cả số 0 ở cuối. 1290000.500 có ba chữ số phần thập phân, và JSON giữ nguyên chúng: "price":1290000.500 trong request body trả về 400, còn "price":1290000.50 trả về 201.
  • Một giá trị có thể vi phạm nhiều constraint cùng lúc. 0.009 tạo ra hai violation, mỗi annotation một cái.
  • Số bắt buộc cần wrapper type. Integer stock có thể là null, nên @NotNull có cái để bắt; một int thì không bao giờ là null.

@Email chấp nhận những địa chỉ nào?

Giá trị@Email
user@example.comhợp lệ
a@bhợp lệ
user@localhosthợp lệ
user@example.chợp lệ
user@127.0.0.1hợp lệ
user@[127.0.0.1]hợp lệ
nguyễn@example.vnhợp lệ
a.@b.commust be a well-formed email address
user@@example.commust be a well-formed email address
user@example..commust be a well-formed email address
user name@example.commust be a well-formed email address
user@-example.commust be a well-formed email address
""hợp lệ
nullhợp lệ

Hibernate Validator kiểm tra cú pháp của địa chỉ, không kiểm tra địa chỉ đó có nhận được mail hay không. Domain không có dấu chấm, top-level domain chỉ một chữ cái, địa chỉ IP và chữ cái ngoài bảng ASCII ở phần local đều đúng cú pháp. Thứ bị từ chối là cú pháp hỏng: dấu chấm ở cuối phần local, @ thứ hai, hai dấu chấm liền nhau, khoảng trắng, một label của domain bắt đầu bằng -.

Từ đó có hai hệ quả. @Email chấp nhận cả null lẫn "", nên một địa chỉ bắt buộc cần @NotBlank @Email. Và nếu mọi địa chỉ đều phải có dấu chấm trong domain, attribute regexp của chính constraint này thêm được quy tắc đó:

Java
@NotBlank @Email(regexp = ".+@.+\\..+") String contactEmail

Với attribute đó, a@buser@localhost bị từ chối với cùng message must be a well-formed email address, còn user@example.com vẫn qua.

@Pattern và cách escape regex trong Java

sku dùng @Pattern(regexp = "^[A-Z]{3}-\\d{4}$"). Dấu backslash được nhân đôi vì regex nằm trong một string literal của Java: \\d trong source là \d trong pattern, và message in ra đúng như vậy.

skuKết quả
KBD-1001hợp lệ
kbd-1001must match "^[A-Z]{3}-\d{4}$"
KBD-100must match "^[A-Z]{3}-\d{4}$"
KBD1001must match "^[A-Z]{3}-\d{4}$"
" KBD-1001"must match "^[A-Z]{3}-\d{4}$"
KBD-1001 kèm một ký tự xuống dòng ở cuốimust match "^[A-Z]{3}-\d{4}$"
""must match "^[A-Z]{3}-\d{4}$"
nullmust not be null, do @NotNull
  • Toàn bộ giá trị phải khớp. Khoảng trắng ở đầu làm hỏng, ký tự xuống dòng ở cuối cũng vậy. Không có gì bị trim và khớp một phần là chưa đủ, nên hai anchor ^$ là không bắt buộc: @Pattern(regexp = "[A-Z]{3}-\\d{4}") cũng từ chối xKBD-1001KBD-1001x y như vậy.
  • Chuỗi rỗng được kiểm tra và bị từ chối, còn null thì hoàn toàn không được kiểm tra. Đó là lý do sku cần thêm @NotNull.
  • Message mặc định in ra nguyên regex, thứ vô nghĩa với client của API. Phần về message sẽ thay nó.

Constraint cho ngày: @Past, @PastOrPresent và @Future

Kiểm tra vào ngày 2026-09-12:

LocalDate@Past@PastOrPresent@Future
2026-09-11hợp lệhợp lệmust be a future date
2026-09-12, hôm naymust be a past datehợp lệmust be a future date
2026-09-13must be a past datemust be a date in the past or in the presenthợp lệ

Với LocalDate, "present" là ngày hôm nay: @Past từ chối hôm nay còn @PastOrPresent chấp nhận. releasedOn dùng @PastOrPresent, nên sản phẩm phát hành hôm nay là hợp lệ, còn saleEndsOn dùng @Future, nên đợt giảm giá kết thúc hôm nay thì không.

Qua HTTP, với Jackson parse các ngày, các constraint mới cùng báo lỗi một lượt:

Bash
curl -s -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"ab","sku":"KBD-1002","price":1290000.505,"stock":10,"maxPerOrder":0,"releasedOn":"2027-01-01","saleEndsOn":"2026-01-01"}'
Text
{"timestamp":"2026-09-12T07:02:09.436Z","status":400,"error":"Bad Request","path":"/api/products"}
Text
MethodArgumentNotValidException, 5 errors
  price        [1290000.505]  numeric value out of bounds (<9 digits>.<2 digits> expected)
  name         [ab]           size must be between 3 and 100
  releasedOn   [2027-01-01]   must be a date in the past or in the present
  saleEndsOn   [2026-01-01]   must be a future date
  maxPerOrder  [0]            must be greater than or equal to 1

null có vượt qua validation không?

Có, với mọi constraint có sẵn trừ @NotNull, @NotEmpty@NotBlank. Probe record này đặt mười lăm constraint lên mười lăm component, rồi validate một instance mà tất cả đều là null:

Java
record AllNull(@Size(min = 3) String size, @Min(1) Integer min, @Max(10) Integer max,
               @Positive Integer positive, @PositiveOrZero Integer positiveOrZero,
               @DecimalMin("0.01") BigDecimal decimalMin, @Digits(integer = 9, fraction = 2) BigDecimal digits,
               @Email String email, @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String pattern,
               @Past LocalDate past, @PastOrPresent LocalDate pastOrPresent, @Future LocalDate future,
               @NotNull String notNull, @NotEmpty String notEmpty, @NotBlank String notBlank) {}
Text
notBlank: must not be blank
notEmpty: must not be empty
notNull: must not be null

Mười hai trên mười lăm constraint cho qua. Mỗi constraint có sẵn chỉ kiểm tra một đặc điểm của giá trị, còn chuyện giá trị có bắt buộc phải có hay không là một quyết định riêng. Vì vậy một field bắt buộc luôn mang hai annotation: @NotNull @Pattern(...), @NotBlank @Email, @NotNull @DecimalMin(...). Custom constraint ở phần sau cũng theo đúng quy ước này.

Validate object lồng nhau và list

Một sản phẩm trong catalogue còn có tag, một supplier kèm địa chỉ liên hệ, và các variant có tồn kho riêng. Hai record mới chứa phần dữ liệu lồng nhau:

src/main/java/com/example/demo/product/SupplierRequest.java
package com.example.demo.product;
 
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
 
public record SupplierRequest(
        @NotBlank String name,
        @NotNull @Email String contactEmail) {
}
src/main/java/com/example/demo/product/VariantRequest.java
package com.example.demo.product;
 
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
 
public record VariantRequest(
        @NotBlank String label,
        @NotNull @PositiveOrZero Integer stock) {
}

CreateProductRequest có thêm ba component, cố ý chưa có @Valid:

src/main/java/com/example/demo/product/CreateProductRequest.java
public record CreateProductRequest(
        @NotBlank @Size(min = 3, max = 100) String name,
        @NotNull @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String sku,
        @NotNull @DecimalMin("0.01") @Digits(integer = 9, fraction = 2) BigDecimal price,
        @NotNull @PositiveOrZero Integer stock,
        @Min(1) @Max(10) Integer maxPerOrder,
        @PastOrPresent LocalDate releasedOn,
        @Future LocalDate saleEndsOn) { 
        @Future LocalDate saleEndsOn, 
        @Size(max = 5) List<@NotBlank String> tags, 
        SupplierRequest supplier, 
        List<VariantRequest> variants) { 
}

Request này có tag hợp lệ, một supplier với tên để trống và địa chỉ email hỏng, và variant thứ hai có label trống cùng tồn kho âm. -w in status ra sau body:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25,"tags":["keyboard","wireless"],"supplier":{"name":"","contactEmail":"sales@@keychron.example"},"variants":[{"label":"Black","stock":10},{"label":"","stock":-2}]}'
Text
{"id":1,"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25} 201

Tạo thành công, không có dòng log nào. SupplierRequestVariantRequest có constraint, nhưng Hibernate Validator chưa từng đánh giá chúng: nó validate object được đưa vào, và chỉ đi theo một reference sang object khác khi reference đó được đánh dấu @Valid. Đánh dấu cả hai:

src/main/java/com/example/demo/product/CreateProductRequest.java
        @Size(max = 5) List<@NotBlank String> tags,
        SupplierRequest supplier, 
        @Valid SupplierRequest supplier, 
        List<VariantRequest> variants) { 
        List<@Valid VariantRequest> variants) { 
}

Cùng request đó giờ thất bại:

Text
{"timestamp":"2026-09-12T07:06:16.736Z","status":400,"error":"Bad Request","path":"/api/products"} 400
Text
MethodArgumentNotValidException, 4 errors
  variants[1].stock      [-2]                       must be greater than or equal to 0
  supplier.name          []                         must not be blank
  variants[1].label      []                         must not be blank
  supplier.contactEmail  [sales@@keychron.example]  must be a well-formed email address

Tên field giờ là property path: supplier.contactEmail cho một component của object lồng nhau, variants[1].label cho một component của phần tử ở index 1 trong list.

Cùng một CreateProductRequest và cùng body, validate khi không có và khi có @Valid: component của chính record và tags luôn được kiểm tra, supplier và variants bị bỏ qua khi thiếu @Valid và tạo ra bốn field error khi có @Valid

@Valid chỉ validate object lồng nhau khi object đó tồn tại. Một request không có supplier và không có variants trả về 201, vì một reference null không có gì để đi vào. Khi object lồng nhau là bắt buộc, hãy đặt @NotNull cạnh @Valid.

Constraint trên từng phần tử của list

tags hoạt động giống nhau ở cả hai phiên bản. @Size(max = 5) áp dụng cho chính list, còn @NotBlank trong type argument, List<@NotBlank String>, là một container element constraint: nó áp dụng cho từng phần tử. Cả hai đều ràng buộc giá trị của chính component, nên không cái nào cần @Valid. Sáu tag, trong đó một tag để trống:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25,"tags":["keyboard","  ","wireless","rgb","mechanical","usb-c"]}'
Text
{"timestamp":"2026-09-12T07:06:16.712Z","status":400,"error":"Bad Request","path":"/api/products"} 400
Text
MethodArgumentNotValidException, 2 errors
  tags[1]  [  ]                                            must not be blank
  tags     [[keyboard,   , wireless, rgb, mechanical, usb-c]]  size must be between 0 and 5

Phần tử null cũng bị bắt: "tags":["keyboard",null] tạo ra đúng một lỗi tags[1] [null] must not be blank.

Validate một list object

Với list chứa object, @Valid đặt trên type argument, như List<@Valid VariantRequest>. Cách viết cũ, @Valid List<VariantRequest>, vẫn cascade trong Hibernate Validator 9.1.3: một probe record khai báo mỗi cách một list đã báo lỗi [0].label[0].stock cho cả hai. Nhưng cách cũ ghi ra một cảnh báo deprecated khi class được validate lần đầu, kết thúc bằng tên của phần tử bị ảnh hưởng:

Text
HV000271: Using `@Valid` on a container (java.util.List) is deprecated. You should apply the annotation on the type argument(s).

Validate @PathVariable và @RequestParam

Một path variable hay query parameter là một giá trị đơn lẻ, không phải object có constraint của riêng nó, nên constraint được đặt thẳng lên parameter của method. Hai endpoint đọc dữ liệu cho controller (import mới là jakarta.validation.constraints.Max, PositivePositiveOrZero):

src/main/java/com/example/demo/product/ProductController.java
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> get(@PathVariable @Positive Long id) {
        return store.findById(id)
                .map(ProductResponse::from)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
 
    @GetMapping
    public List<ProductResponse> list(@RequestParam(defaultValue = "0") @PositiveOrZero int page,
                                      @RequestParam(defaultValue = "20") @Max(100) int size) {
        return store.findAll(page, size).stream().map(ProductResponse::from).toList();
    }

Class vẫn chưa có @Validated. Một id không hợp lệ:

Bash
curl -i localhost:8119/api/products/-1
Text
HTTP/1.1 400 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:02:09 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:02:09.323Z","status":400,"error":"Bad Request","path":"/api/products/-1"}

Hai query parameter không hợp lệ, sau đó là một trang hợp lệ khi đã có một sản phẩm được tạo:

Bash
curl -s -w ' %{http_code}\n' 'localhost:8119/api/products?page=-1&size=500'
Text
{"timestamp":"2026-09-12T07:02:09.361Z","status":400,"error":"Bad Request","path":"/api/products"} 400
Bash
curl -s -w ' %{http_code}\n' 'localhost:8119/api/products?size=100'
Text
[{"id":1,"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":25}] 200

Đây là method validation có sẵn trong Spring MVC từ Spring Framework 6.1. Khi một parameter của method trong controller mang constraint annotation, Spring MVC tự validate các argument trước khi gọi method và báo lỗi bằng HandlerMethodValidationException. Response trông giống hệt trường hợp @RequestBody.

Log thì không. Log để trống, vì HandlerMethodValidationException kế thừa ResponseStatusException, nên nó được ResponseStatusExceptionResolver resolve, và resolver này chỉ ghi log khi bật spring.mvc.log-resolved-exception. Chạy với --spring.mvc.log-resolved-exception=true sẽ thấy dòng log cho id không hợp lệ:

Text
WARN 38131 --- [demo] [nio-8119-exec-1] .w.s.m.a.ResponseStatusExceptionResolver : Resolved [org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST "Validation failure"]

Kể cả khi đó, dòng log vẫn không cho biết parameter nào sai hay sai vì sao. Thông tin đó nằm trong object exception, nơi một exception handler sẽ đọc nó.

@Validated trên controller thay đổi điều gì?

Nhiều ví dụ đặt @Validated lên class controller, vì trước Spring Framework 6.1 đó là cách để validate @PathVariable@RequestParam. Thêm nó vào chính controller này:

src/main/java/com/example/demo/product/ProductController.java
@Validated
@RestController
@RequestMapping("/api/products")
public class ProductController {

Cùng id không hợp lệ đó:

Bash
curl -i localhost:8119/api/products/-1
Text
HTTP/1.1 500 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 12 Sep 2026 07:06:18 GMT
Connection: close
 
{"timestamp":"2026-09-12T07:06:18.913Z","status":500,"error":"Internal Server Error","path":"/api/products/-1"}

và một dòng ERROR trong log, theo sau là toàn bộ stack trace:

Text
ERROR 38245 --- [demo] [nio-8119-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: jakarta.validation.ConstraintViolationException: get.id: must be greater than 0] with root cause
 
jakarta.validation.ConstraintViolationException: get.id: must be greater than 0

?size=500 cũng thất bại theo cùng cách, với list.size: must be less than or equal to 100.

Status thay đổi vì validation đã chuyển chỗ. methodValidationPostProcessor của Boot bọc mọi bean có @Validated trong một proxy validate argument trước mỗi lời gọi method; phần về service layer sẽ cho thấy class của proxy đó. Với một controller có @Validated, Spring MVC để proxy đảm nhận việc validate parameter thay vì tự làm. Proxy ném jakarta.validation.ConstraintViolationException, không exception resolver mặc định nào của Spring MVC xử lý nó, và servlet container báo nó như một lỗi server. Message có dạng method.parameter: message, như get.id: must be greater than 0.

Request body không bị ảnh hưởng, vì argument resolver validate body trước khi proxy được gọi tới:

RequestClass không có @ValidatedClass có @Validated
GET /api/products/-1400, HandlerMethodValidationException, không có log500, ConstraintViolationException: get.id: must be greater than 0, log ERROR
GET /api/products?size=500400, HandlerMethodValidationException, không có log500, ConstraintViolationException: list.size: must be less than or equal to 100, log ERROR
POST /api/products với body không hợp lệ400, MethodArgumentNotValidException, log WARN400, MethodArgumentNotValidException, log WARN

⚠️ Với Spring Boot 4, đừng đặt @Validated lên một @RestController. Nó khiến mọi path variable và query parameter không hợp lệ chuyển từ 400 sang 500, kèm stack trace trong log. Chỉ riêng constraint annotation trên parameter là đủ.

Ba làn: @Valid trên request body kết thúc bằng MethodArgumentNotValidException và 400 kèm log WARN, constraint trên path và query parameter kết thúc bằng HandlerMethodValidationException và 400 không có log, còn cùng các parameter đó trên controller có @Validated đi qua AOP proxy tới ConstraintViolationException và 500

@PathVariable có constraint đặt cạnh @Valid @RequestBody

Method validation còn một hệ quả nữa. PUT /api/products/{id} thay thế một sản phẩm, và ngoài body, path variable của nó cũng có constraint. Store có thêm method replace tương ứng:

src/main/java/com/example/demo/product/ProductController.java
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> replace(@PathVariable @Positive Long id,
                                                   @Valid @RequestBody CreateProductRequest request) {
        return store.replace(id, request)
                .map(ProductResponse::from)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
src/main/java/com/example/demo/product/ProductStore.java
    public Optional<Product> replace(Long id, CreateProductRequest request) {
        if (!products.containsKey(id)) {
            return Optional.empty();
        }
        Product product = new Product(id, request.name(), request.sku(), request.price(), request.stock());
        products.put(id, product);
        return Optional.of(product);
    }

Id hợp lệ, body không hợp lệ, trên controller không có @Validated:

Bash
curl -s -w ' %{http_code}\n' -X PUT localhost:8119/api/products/1 -H 'Content-Type: application/json' -d '{"name":"  ","sku":"KBD-1001","price":0,"stock":25}'
Text
{"timestamp":"2026-09-12T07:02:09.459Z","status":400,"error":"Bad Request","path":"/api/products/1"} 400

Không có dòng WARN nào, dù body không hợp lệ. Với --spring.mvc.log-resolved-exception=true, log cho thấy lý do:

Text
WARN 38131 --- [demo] [nio-8119-exec-3] .w.s.m.a.ResponseStatusExceptionResolver : Resolved [org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST "Validation failure"]

Chỉ cần một parameter bất kỳ của method có constraint, Spring MVC sẽ validate toàn bộ argument của method đó trong một lượt, kể cả @Valid @RequestBody, và báo kết quả bằng một HandlerMethodValidationException duy nhất. Status vẫn là 400, nhưng type của exception khác với POST /api/products, và lỗi của body biến mất khỏi log mặc định. Một exception handler cho lỗi validation phải xử lý được cả hai type.

Để đầy đủ: khi class có @Validated, cùng lệnh PUT với body không hợp lệ quay về MethodArgumentNotValidException (Validation failed for argument [1]), còn PUT /api/products/-1 với body hợp lệ thành 500 với replace.id: must be greater than 0.

@Valid và @Validated, validation group

Cả hai annotation đều kích hoạt validation, nhưng chúng đến từ những nơi khác nhau và làm những việc khác nhau:

@Valid@Validated
Packagejakarta.validationorg.springframework.validation.annotation
Định nghĩa bởiđặc tả Jakarta ValidationSpring
Trên parameter @RequestBodyvalidate group Default, MethodArgumentNotValidExceptiongiống vậy khi không có attribute, hoặc chỉ các group được liệt kê
Groupkhông hỗ trợ@Validated(OnCreate.class)
Cascade vào field lồng nhau hoặc type argumentcó; target của nó là method, field, constructor, parameter và type usekhông; target của nó chỉ là type, method và parameter
Trên một classkhông được phépbật method validation dựa trên proxy cho bean đó

Một @Validated @RequestBody CreateProductRequest request không kèm attribute tạo ra đúng MethodArgumentNotValidException như @Valid. Khác biệt nằm ở attribute value, dùng để chọn validation group.

Validation group với @Validated(OnCreate.class)

Tạo sản phẩm cần tên, SKU và giá. Một lệnh cập nhật từng phần bằng PATCH chấp nhận bất kỳ tập con nào của các field, nhưng field nào đã gửi thì vẫn phải hợp lệ. Group cho phép một record thể hiện cả hai. Một group chỉ là một marker interface:

src/main/java/com/example/demo/product/OnCreate.java
package com.example.demo.product;
 
public interface OnCreate {
}
src/main/java/com/example/demo/product/OnUpdate.java
package com.example.demo.product;
 
public interface OnUpdate {
}

Mỗi constraint liệt kê các group mà nó thuộc về:

src/main/java/com/example/demo/product/ProductRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
 
public record ProductRequest(
        @NotBlank(groups = OnCreate.class)
        @Size(min = 3, max = 100, groups = {OnCreate.class, OnUpdate.class})
        String name,
 
        @NotNull(groups = OnCreate.class)
        @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", groups = {OnCreate.class, OnUpdate.class})
        String sku,
 
        @NotNull(groups = OnCreate.class)
        @DecimalMin(value = "0.01", groups = {OnCreate.class, OnUpdate.class})
        BigDecimal price,
 
        @PositiveOrZero
        Integer stock) {
}

và mỗi endpoint chọn một group bằng @Validated:

src/main/java/com/example/demo/product/ProductController.java
    @PostMapping
    public ResponseEntity<ProductResponse> create(@Validated(OnCreate.class) @RequestBody ProductRequest request) {
        Product product = store.save(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(ProductResponse.from(product));
    }
 
    @PatchMapping("/{id}")
    public ResponseEntity<ProductResponse> update(@PathVariable Long id,
                                                  @Validated(OnUpdate.class) @RequestBody ProductRequest request) {
        return store.update(id, request)
                .map(ProductResponse::from)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

store.savestore.update là các overload cho ProductRequest; update chép các field khác null lên sản phẩm đang lưu. Kết quả theo từng request:

RequestBodyKết quả
POST /api/products{"name":" ","price":0,"stock":-5}400: name size must be between 3 and 100, sku must not be null, name must not be blank, price must be greater than or equal to 0.01
PATCH /api/products/1{"price":0}400: price must be greater than or equal to 0.01
PATCH /api/products/1{"sku":"KBD-2002"}200
PATCH /api/products/1{"name":" "}400: chỉ name size must be between 3 and 100

Đó chính là mục đích của group. @NotBlank(groups = OnCreate.class) chỉ chạy khi tạo mới, nên lệnh cập nhật chấp nhận request không có tên, còn @Size(..., groups = {OnCreate.class, OnUpdate.class}) kiểm tra tên ở cả hai trường hợp.

Giờ hãy nhìn stock. @PositiveOrZero của nó không có groups, nên nó thuộc group Default, và @Validated(OnCreate.class) chỉ validate OnCreate, không gì khác. Lệnh POST đầu tiên ở trên đã gửi "stock":-5 và không lỗi nào nhắc tới nó. Gửi kèm các field hợp lệ khác, giá trị đó được lưu luôn:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":-5}'
Text
{"id":1,"name":"Mechanical keyboard","sku":"KBD-1001","price":1290000,"stock":-5} 201

PATCH /api/products/1 với {"stock":-7} cũng trả về 200 và lưu -7. Một constraint không có groups sẽ lặng lẽ bị bỏ qua ngay khi endpoint chỉ định một group. Cách sửa là cho mỗi group bao gồm luôn Default:

src/main/java/com/example/demo/product/OnCreate.java
package com.example.demo.product;
 
import jakarta.validation.groups.Default; 
 
public interface OnCreate { 
public interface OnCreate extends Default { 
}

Khi OnUpdate cũng được đổi như vậy, lệnh POST với "stock":-5 trả về 400 kèm stock [-5] must be greater than or equal to 0, và lệnh PATCH với "stock":-7 trả về 400 với cùng message.

Khi nào tách DTO riêng đơn giản hơn dùng group?

Phần lớn các trường hợp. Group gắn hai thao tác vào một class, mọi constraint phải lặp lại groups, một constraint quên khai báo sẽ biến mất mà không có cảnh báo nào, và người đọc phải dò từng attribute mới biết quy tắc nào áp dụng cho endpoint nào. Hai record, CreateProductRequest@NotBlank@NotNull ở những chỗ việc tạo mới cần, và UpdateProductRequest chỉ có các constraint về định dạng, nói cùng một điều mà không cần attribute nào, và mỗi record có thể có thêm field mà record kia không có.

Group đáng dùng khi hai hình dạng thật sự giống hệt nhau và chỉ khác ở tính bắt buộc, hoặc khi một class được validate qua nhiều bước riêng biệt, chẳng hạn một form nhiều trang được validate theo từng trang. Với endpoint tạo và cập nhật trong một REST API, hãy bắt đầu bằng hai DTO.

Tùy chỉnh validation message

Message mặc định được viết cho lập trình viên: must match "^[A-Z]{3}-\d{4}$" chính xác nhưng vô dụng với client của API. Mọi constraint đều có attribute message, và attribute này nhận ba loại nội dung. Quay lại CreateProductRequest:

src/main/java/com/example/demo/product/CreateProductRequest.java
public record CreateProductRequest(
        @NotBlank @Size(min = 3, max = 100) String name, 
        @NotBlank(message = "name is required") 
        @Size(min = 3, max = 100, message = "name must be between {min} and {max} characters") 
        String name, 
 
        @NotNull @Pattern(regexp = "^[A-Z]{3}-\\d{4}$") String sku, 
        @NotNull
        @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "'${validatedValue}' is not a SKU like ABC-1234") 
        String sku, 
 
        @NotNull @DecimalMin("0.01") @Digits(integer = 9, fraction = 2) BigDecimal price, 
        @NotNull
        @DecimalMin(value = "0.01", message = "{product.price.min}") 
        @Digits(integer = 9, fraction = 2) 
        BigDecimal price, 
 
        @NotNull @PositiveOrZero Integer stock,
        // maxPerOrder, releasedOn, saleEndsOn, tags, supplier and variants are unchanged

{product.price.min} là một key, nên cần một file ở gốc classpath, cùng một bản tiếng Việt đặt ngay cạnh:

src/main/resources/ValidationMessages.properties
product.price.min=price must be at least {value}
src/main/resources/ValidationMessages_vi.properties
product.price.min=giá phải từ {value} trở lên

Một request vi phạm bốn quy tắc, không có header Accept-Language:

Bash
curl -s -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"ab","sku":"kbd-1","price":0,"stock":-1}'
Text
MethodArgumentNotValidException, 4 errors
  name   [ab]     name must be between 3 and 100 characters
  sku    [kbd-1]  'kbd-1' is not a SKU like ABC-1234
  stock  [-1]     must be greater than or equal to 0
  price  [0]      price must be at least 0.01
  • Chữ viết sẵn, như name is required, được dùng nguyên văn.
  • {min}{max} là attribute của annotation, được thay bằng 3100. {value} trong file properties cũng hoạt động như vậy: đó là value của @DecimalMin.
  • {product.price.min} không phải attribute, nên được resolve như một key từ ValidationMessages.properties. Message mặc định cũng là key: message của @PositiveOrZero{jakarta.validation.constraints.PositiveOrZero.message}, và phần tiếp theo dùng đúng điều đó để dịch một message.
  • ${validatedValue} là một biểu thức Expression Language, được đánh giá bằng phần hiện thực EL mà starter mang theo. Nó chèn giá trị bị từ chối vào message. Chỉ dùng nó trong message do bạn viết; đừng bao giờ ghép message template từ dữ liệu người dùng, vì chính template sẽ được đánh giá.

Accept-Language có đổi message không?

Cùng request đó, gửi với bốn giá trị Accept-Language khác nhau:

Bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -H 'Accept-Language: de' -d '{"name":"ab","sku":"kbd-1","price":0,"stock":-1}'
Text
400
Accept-Languagestock, message mặc địnhprice, {product.price.min}
không cómust be greater than or equal to 0price must be at least 0.01
vimust be greater than or equal to 0giá phải từ 0.01 trở lên
demuss größer-gleich 0 seinprice must be at least 0.01
frdoit être supérieur ou égal à 0price must be at least 0.01

Message của namesku giống hệt nhau ở cả bốn lần chạy, vì một message viết sẵn và một biểu thức EL không có gì để dịch.

Message đi theo locale của request. LocalValidatorFactoryBean của Spring bọc message interpolator trong một LocaleContextMessageInterpolator, thứ dùng locale mà Spring MVC đã resolve cho request hiện tại, và giá trị mặc định của spring.web.locale-resolver trong Boot là accept-header. Với mỗi message, Hibernate Validator tìm key trong các file ValidationMessages của bạn cho locale đó trước, sau đó mới tới bundle của chính nó.

Các bundle đó phủ 27 locale trong Hibernate Validator 9.1.3, gồm cả defr, nhưng không có tiếng Việt. Vì vậy request với Accept-Language: vi nhận message tiếng Việt do bạn viết và tiếng Anh cho mọi message mặc định. Để dịch một message mặc định, thêm key của nó vào file của bạn:

src/main/resources/ValidationMessages_vi.properties
jakarta.validation.constraints.PositiveOrZero.message=phải lớn hơn hoặc bằng 0

Với dòng đó, request vi báo stock [-1] phải lớn hơn hoặc bằng 0.

Request không có Accept-Language chưa chắc nhận tiếng Anh. Nó nhận locale mặc định của JVM: cùng file jar đó, khởi động với JAVA_TOOL_OPTIONS="-Duser.language=de -Duser.country=DE", đã trả lời request ấy bằng muss größer-gleich 0 sein. Một application cần message giống nhau trên mọi máy thì nên đặt locale một cách tường minh.

ValidationMessages.properties được đọc với encoding nào?

UTF-8. Đoạn tiếng Việt trong ValidationMessages_vi.properties ở trên được lưu dưới dạng UTF-8 thông thường, không dùng escape \u, và hiện ra nguyên vẹn trong log. Đặt cùng dòng đó vào file gốc ValidationMessages.properties, không có file _vi nào, cũng cho ra giá phải từ 0.01 trở lên chính xác như vậy, với mọi Accept-Language.

Điều này ngược với application.properties, file mà Spring Boot mặc định giải mã theo ISO-8859-1 và khiến chính các ký tự đó bị lỗi. Khác biệt nằm ở cách nạp file: Hibernate Validator đọc các file message qua java.util.ResourceBundle, và từ Java 9 ResourceBundle đọc file properties theo UTF-8.

Viết custom constraint

@Pattern kiểm tra hình dạng của SKU. Catalogue cần thêm một quy tắc nữa: ba chữ cái phải là mã của một danh mục có tồn tại. Danh mục là dữ liệu của application, nên quy tắc này thuộc về một Spring bean, thứ mà ở Chương 4 sẽ là một repository:

src/main/java/com/example/demo/product/CategoryRegistry.java
package com.example.demo.product;
 
import java.util.Set;
 
import org.springframework.stereotype.Component;
 
@Component
public class CategoryRegistry {
 
    private final Set<String> codes = Set.of("KBD", "MSE", "MON");
 
    public boolean exists(String code) {
        return codes.contains(code);
    }
}

Một custom constraint gồm hai phần. Phần annotation:

src/main/java/com/example/demo/product/ValidSku.java
package com.example.demo.product;
 
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;
 
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
 
@Documented
@Constraint(validatedBy = SkuValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidSku {
 
    String message() default "{com.example.demo.product.ValidSku.message}";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}

và phần validator:

src/main/java/com/example/demo/product/SkuValidator.java
package com.example.demo.product;
 
import java.util.regex.Pattern;
 
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
 
public class SkuValidator implements ConstraintValidator<ValidSku, String> {
 
    private static final Pattern FORMAT = Pattern.compile("^[A-Z]{3}-\\d{4}$");
 
    private final CategoryRegistry categories;
 
    public SkuValidator(CategoryRegistry categories) {
        this.categories = categories;
    }
 
    @Override
    public boolean isValid(String sku, ConstraintValidatorContext context) {
        if (sku == null) {
            return true;
        }
        return FORMAT.matcher(sku).matches() && categories.exists(sku.substring(0, 3));
    }
}

Mỗi phần có một nhiệm vụ:

  • @Constraint(validatedBy = SkuValidator.class) khiến @ValidSku trở thành một constraint và chỉ ra class kiểm tra nó.
  • message, groupspayload bắt buộc phải có trên mọi constraint annotation. Một annotation thiếu payload thất bại ngay khi được dùng, với jakarta.validation.ConstraintDefinitionException: HV000074: com.example.demo.probe.NoPayload contains Constraint annotation, but does not contain a payload parameter. Giá trị mặc định của message là một key, cùng định dạng với các message có sẵn.
  • @Target cho biết annotation được đặt ở đâu: FIELD bao gồm component của record, PARAMETER cho parameter của controller hoặc service, TYPE_USE cho type argument như List<@ValidSku String>.
  • ConstraintValidator<ValidSku, String> gắn validator với annotation và với type mà nó validate.
  • return true khi gặp null theo quy ước của các constraint có sẵn. Tính bắt buộc vẫn do @NotNull quyết định.
  • Constructor nhận một Spring bean. LocalValidatorFactoryBean của Spring tạo validator qua một SpringConstraintValidatorFactory, thứ dựng chúng bằng bean factory của application context, nên constructor injection hoạt động như với mọi component khác.

Annotation này thay cho @Pattern trên sku, còn message key được thêm vào cả hai file properties:

src/main/java/com/example/demo/product/CreateProductRequest.java
        @NotNull
        @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "'${validatedValue}' is not a SKU like ABC-1234") 
        String sku, 
        @NotNull @ValidSku String sku, 
src/main/resources/ValidationMessages.properties
com.example.demo.product.ValidSku.message=must be a SKU like ABC-1234 with a known category code
src/main/resources/ValidationMessages_vi.properties
com.example.demo.product.ValidSku.message=phải là SKU dạng ABC-1234 với mã danh mục đã đăng ký

Gửi từng sản phẩm một, mọi field trừ sku đều hợp lệ:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"ABC-1234","price":1290000,"stock":25}'
Text
{"timestamp":"2026-09-12T07:23:40.727Z","status":400,"error":"Bad Request","path":"/api/products"} 400
sku gửi lênKết quả
KBD-1001201
ABC-1234400: must be a SKU like ABC-1234 with a known category code
kbd-1001400: must be a SKU like ABC-1234 with a known category code
không có sku400: must not be null, chỉ do @NotNull
ABC-1234 với Accept-Language: vi400: phải là SKU dạng ABC-1234 với mã danh mục đã đăng ký

ABC-1234 đúng hình dạng nhưng mã danh mục không tồn tại, nên CategoryRegistry được inject đã làm đúng việc của nó. Trong dòng WARN, message code đầu tiên của lỗi đó là ValidSku.createProductRequest.sku: tên ngắn của annotation đứng ở vị trí mà NotBlank hay Pattern từng đứng.

Các phần của @ValidSku kết nối với nhau thế nào: annotation trên component của record, @Constraint chỉ tới SkuValidator, bean CategoryRegistry được inject vào constructor, isValid trả về false, message key được resolve từ ValidationMessages_vi.properties, và message nằm trong field error

Constraint cấp class cho minPrice và maxPrice

Một số quy tắc liên quan tới hai field. Tìm kiếm sản phẩm nhận một khoảng giá, và minPrice không được lớn hơn maxPrice. Constraint trên field chỉ thấy giá trị của chính nó, nên constraint này được đặt trên class:

src/main/java/com/example/demo/product/PriceRange.java
package com.example.demo.product;
 
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;
 
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
 
@Documented
@Constraint(validatedBy = PriceRangeValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface PriceRange {
 
    String message() default "must be greater than or equal to minPrice";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}
src/main/java/com/example/demo/product/ProductSearchRequest.java
package com.example.demo.product;
 
import java.math.BigDecimal;
 
import jakarta.validation.constraints.PositiveOrZero;
 
@PriceRange
public record ProductSearchRequest(
        String query,
        @PositiveOrZero BigDecimal minPrice,
        @PositiveOrZero BigDecimal maxPrice) {
}

Validator nhận toàn bộ record. Khi khoảng giá bị đảo ngược, nó thay violation mặc định bằng một violation gắn vào maxPrice:

src/main/java/com/example/demo/product/PriceRangeValidator.java
package com.example.demo.product;
 
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
 
public class PriceRangeValidator implements ConstraintValidator<PriceRange, ProductSearchRequest> {
 
    @Override
    public boolean isValid(ProductSearchRequest request, ConstraintValidatorContext context) {
        if (request.minPrice() == null || request.maxPrice() == null
                || request.minPrice().compareTo(request.maxPrice()) <= 0) {
            return true;
        }
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                .addPropertyNode("maxPrice")
                .addConstraintViolation();
        return false;
    }
}

Một endpoint tìm kiếm và method trong store để dùng nó:

src/main/java/com/example/demo/product/ProductController.java
    @PostMapping("/search")
    public List<ProductResponse> search(@Valid @RequestBody ProductSearchRequest request) {
        return store.search(request).stream().map(ProductResponse::from).toList();
    }
src/main/java/com/example/demo/product/ProductStore.java
    public List<Product> search(ProductSearchRequest request) {
        return products.values().stream()
                .filter(p -> request.query() == null || p.name().toLowerCase().contains(request.query().toLowerCase()))
                .filter(p -> request.minPrice() == null || p.price().compareTo(request.minPrice()) >= 0)
                .filter(p -> request.maxPrice() == null || p.price().compareTo(request.maxPrice()) <= 0)
                .toList();
    }

Một khoảng giá bị đảo ngược:

Bash
curl -s -w ' %{http_code}\n' -X POST localhost:8119/api/products/search -H 'Content-Type: application/json' -d '{"minPrice":2000000,"maxPrice":1000000}'
Text
{"timestamp":"2026-09-12T07:23:40.775Z","status":400,"error":"Bad Request","path":"/api/products/search"} 400

Lỗi bên trong dòng WARN, lần này không rút gọn vì chính hình dạng của nó là điều cần xem:

Text
Field error in object 'productSearchRequest' on field 'maxPrice': rejected value [1000000]; codes [PriceRange.productSearchRequest.maxPrice,PriceRange.maxPrice,PriceRange.java.math.BigDecimal,PriceRange]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [productSearchRequest.maxPrice,maxPrice]; arguments []; default message [maxPrice]]; default message [must be greater than or equal to minPrice]]

Một constraint cấp class, nhưng được báo như một field error trên maxPrice, kèm rejected value của field đó. Với {"minPrice":2000000,"maxPrice":-1}, cả hai quy tắc cùng báo lỗi trên field này: must be greater than or equal to minPricemust be greater than or equal to 0.

Cả hai lời gọi trước return false đều quan trọng:

  • Không có addPropertyNode, một validator chỉ đơn giản trả về false sẽ tạo ra một lỗi global: Error in object 'productSearchRequest': codes [PriceRange.productSearchRequest,PriceRange]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [productSearchRequest]; arguments []; default message []]; default message [must be greater than or equal to minPrice]]. Không có field, không có rejected value, nên client không biết phải đánh dấu ô nhập nào.
  • Không có disableDefaultConstraintViolation(), violation mặc định được báo cùng với violation tùy chỉnh. Một bản sao của validator chỉ bỏ đúng lời gọi đó đã trả về hai violation cho cùng khoảng giá bị đảo ngược, một cái có property path rỗng và một cái trên maxPrice.

Validation bên ngoài web layer

Controller không phải lối vào duy nhất. Một sản phẩm cũng có thể đến từ một lần import CSV, một message queue hay một scheduled job, và những đường đó không bao giờ đi qua @Valid @RequestBody. @Validated trên một class service áp dụng cùng các constraint cho các lời gọi method của nó:

src/main/java/com/example/demo/product/ProductService.java
package com.example.demo.product;
 
import jakarta.validation.Valid;
 
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
 
@Service
@Validated
public class ProductService {
 
    private final ProductStore store;
 
    public ProductService(ProductStore store) {
        this.store = store;
    }
 
    public Product create(@Valid CreateProductRequest request) {
        return store.save(request);
    }
}

Gọi nó từ một ApplicationRunner với một dòng dữ liệu không hợp lệ:

Java
CreateProductRequest row = new CreateProductRequest(null, "ABC-1234", new BigDecimal("0"), 5,
        null, null, null, null, null, null);
try {
    productService.create(row);
} catch (ConstraintViolationException e) {
    System.out.println(e.getClass().getName());
    System.out.println(e.getMessage());
}
Text
jakarta.validation.ConstraintViolationException
create.request.price: price must be at least 0.01, create.request.sku: must be a SKU like ABC-1234 with a known category code, create.request.name: name is required

Lời gọi chưa bao giờ tới được store.save. Bean được inject không phải một ProductService thuần: productService.getClass().getName() in ra com.example.demo.product.ProductService$$SpringCGLIB$$0. methodValidationPostProcessor của Boot, một FilteredMethodValidationPostProcessor, đã tạo proxy đó vì có @Validated, và proxy validate argument trước khi chuyển lời gọi đi tiếp. Path trong mỗi message có dạng method.parameter.property, và @Valid trên parameter là thứ khiến validation đi vào các component của record.

Nếu exception thoát ra khỏi một controller, kết quả là cùng lỗi 500 như với một controller có @Validated. Một method của controller truyền body chưa validate vào productService.create đã tạo ra:

Text
ERROR 44585 --- [demo] [nio-8119-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: jakarta.validation.ConstraintViolationException: create.request.sku: must be a SKU like ABC-1234 with a known category code, create.request.name: name is required, create.request.price: price must be at least 0.01] with root cause

Hãy validate ở rìa của application, nơi bạn trả được 400, và coi validation ở service là lưới an toàn cho các lối vào khác.

Gọi Validator trực tiếp trong code

Một lần import không nên dừng ở dòng lỗi đầu tiên; nó nên bỏ qua dòng đó và báo cáo lại. Để làm vậy, hãy inject jakarta.validation.Validator, chính là bean defaultValidator của Boot, rồi tự gọi nó:

src/main/java/com/example/demo/product/ProductImporter.java
package com.example.demo.product;
 
import java.util.Set;
 
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
 
@Component
public class ProductImporter {
 
    private static final Logger log = LoggerFactory.getLogger(ProductImporter.class);
 
    private final Validator validator;
    private final ProductService productService;
 
    public ProductImporter(Validator validator, ProductService productService) {
        this.validator = validator;
        this.productService = productService;
    }
 
    public boolean importRow(int line, CreateProductRequest row) {
        Set<ConstraintViolation<CreateProductRequest>> violations = validator.validate(row);
        if (!violations.isEmpty()) {
            violations.forEach(v -> log.warn("line {}, {}: {}", line, v.getPropertyPath(), v.getMessage()));
            return false;
        }
        productService.create(row);
        return true;
    }
}

Import một dòng hợp lệ, rồi tới dòng không hợp lệ ở trên:

Java
System.out.println("import line 1 -> " + importer.importRow(1, validRow));
System.out.println("import line 2 -> " + importer.importRow(2, row));
Text
import line 1 -> true
WARN 44585 --- [demo] [           main] c.example.demo.product.ProductImporter   : line 2, price: price must be at least 0.01
WARN 44585 --- [demo] [           main] c.example.demo.product.ProductImporter   : line 2, sku: must be a SKU like ABC-1234 with a known category code
WARN 44585 --- [demo] [           main] c.example.demo.product.ProductImporter   : line 2, name: name is required
import line 2 -> false

validate không ném exception nào; nó trả về một Set<ConstraintViolation>, và mỗi violation mang property path, message và rejected value của nó. Đây là cùng Validator mà Boot đã cấu hình, với cùng các file message và cùng validator factory hiểu Spring, điều quan trọng với @ValidSku: một validator dựng bên ngoài Spring bằng Validation.buildDefaultValidatorFactory() hoàn toàn không tạo được SkuValidator và thất bại với jakarta.validation.ValidationException: HV000064: Unable to instantiate ConstraintValidator: com.example.demo.product.SkuValidator., với nguyên nhân là java.lang.NoSuchMethodException: com.example.demo.product.SkuValidator.<init>().

Validation chạy ở đâu và ném ra exception gì

Mọi dòng đều lấy từ các lần chạy trong bài:

Constraint đặt ở đâuThứ kích hoạt validationExceptionKết quả mặc định
@Valid hoặc @Validated trên parameter @RequestBodySpring MVC, trong lúc resolve argumentMethodArgumentNotValidException400, một dòng log WARN chứa mọi field error
constraint trên @PathVariable hoặc @RequestParam, class không có @Validatedmethod validation có sẵn của Spring MVCHandlerMethodValidationException400, không có log
@Valid @RequestBody trên một method có thêm parameter mang constraintcùng method validation có sẵn đó, cho mọi argumentHandlerMethodValidationException400, không có log
constraint trên @PathVariable hoặc @RequestParam, controller có @ValidatedAOP proxy từ methodValidationPostProcessorConstraintViolationException500, log ERROR kèm stack trace
parameter có @Valid của một service có @Validatedcùng AOP proxy đó, ở mỗi lời gọiConstraintViolationExceptionnơi gọi tự xử lý; 500 nếu thoát ra khỏi controller
mọi object truyền vào validator.validate(…)code của bạnkhông có, một Set<ConstraintViolation> được trả vềtùy code của bạn
một class @Validated @ConfigurationPropertiesbinding lúc khởi độngConfigurationPropertiesBindExceptionapplication không khởi động được
JSON body mà Jackson không đọc đượckhông gì cả: validation không chạyHttpMessageNotReadableException400, một dòng log WARN

Dòng cuối là ranh giới của @Valid. Một body với "price":"abc" tạo ra dòng log này và không có lỗi validation nào, vì không có object nào để validate:

Text
WARN 44585 --- [demo] [nio-8119-exec-4] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.math.BigDecimal` from String "abc": not a valid representation]

FAQ

Vì sao @Valid bị bỏ qua trong controller Spring Boot?

Các lần chạy ở trên tìm ra bốn nguyên nhân. Không có Bean Validation provider trên classpath, để lại dòng INFO Failed to set up a Bean Validation provider lúc khởi động; hãy thêm spring-boot-starter-validation. Constraint nằm trên một object lồng nhau mà component chứa nó thiếu @Valid. Endpoint dùng @Validated(SomeGroup.class) còn constraint không khai báo groups, nên nó nằm trong group Default. Hoặc giá trị là null, thứ mà mọi constraint trừ @NotNull, @NotEmpty@NotBlank đều chấp nhận.

@Valid và @Validated khác nhau thế nào?

@Valid là chuẩn Jakarta: nó kích hoạt validation cho một parameter và cascade vào object lồng nhau cũng như type argument. @Validated là của Spring: trên parameter nó làm điều tương tự và chọn được validation group, còn trên class nó bật method validation dựa trên proxy. Dùng @Valid cho request body và field lồng nhau, @Validated(Group.class) khi cần group, và @Validated trên class cho service.

Vì sao validate @PathVariable trả về 500 thay vì 400?

Vì class controller có @Validated. Annotation đó khiến Spring validate qua một AOP proxy, proxy ném ConstraintViolationException, và Spring MVC trả nó về thành 500. Bỏ @Validated khỏi controller và giữ constraint trên parameter: method validation có sẵn của Spring MVC sẽ trả 400 với HandlerMethodValidationException.

Nên dùng @NotNull, @NotEmpty hay @NotBlank cho String?

Hầu như luôn là @NotBlank. @NotNull chấp nhận """ ", còn @NotEmpty chấp nhận " ". Giữ @NotNull cho các type không phải chuỗi như BigDecimal, Integer và object lồng nhau.

ConstraintValidator có dùng được Spring bean không?

Có, qua constructor, khi validator được tạo bởi Validator của Spring Boot: @Valid trong controller, @Validated trên một bean, hoặc một jakarta.validation.Validator được inject. SkuValidator ở trên nhận CategoryRegistry theo đúng cách đó. Một factory dựng bằng Validation.buildDefaultValidatorFactory() không biết gì về Spring và thất bại với HV000064: Unable to instantiate ConstraintValidator.

Làm sao trả về các lỗi validation trong response body?

Mặc định body của 400 chỉ có timestamp, status, errorpath, còn các field error nằm bên trong exception. Xử lý MethodArgumentNotValidExceptionHandlerMethodValidationException ở một chỗ và chuyển chúng thành một body có cấu trúc là chủ đề của bài tiếp theo, bài cũng trả 422 cho body không qua được validation.

Kết luận

Bean Validation trong Spring Boot 4.1.1 bắt đầu từ spring-boot-starter-validation, starter mang theo Hibernate Validator 9.1.3; nếu classpath chỉ có API, @Valid bị bỏ qua mà không báo gì. Constraint trên request record cộng với @Valid trên @RequestBody cho ra 400 thông qua MethodArgumentNotValidException, còn field error nằm trong một dòng log WARN chứ không nằm trong response. Các constraint có sẵn hẹp hơn tên gọi của chúng: null qua được tất cả trừ nhóm @NotNull, @NotBlank là constraint duy nhất từ chối chuỗi toàn khoảng trắng, @Email chấp nhận a@b, và @Digits tính cả số 0 ở cuối. Object lồng nhau và phần tử trong list chỉ được validate qua @Valid, trong khi container element constraint như List<@NotBlank String> không cần gì thêm.

Constraint trên @PathVariable@RequestParam hoạt động mà không cần annotation nào trên class và ném HandlerMethodValidationException, exception được resolve mà không ghi log; đặt @Validated lên controller khiến chính các lỗi đó thành 500. Group dùng được, nhưng một constraint không có groups sẽ biến mất ngay khi endpoint chỉ định một group. Message nhận attribute, key và EL, file của chúng dùng UTF-8, và ngôn ngữ của chúng đi theo Accept-Language, nếu không có thì theo locale của JVM. Một custom constraint gồm một annotation và một ConstraintValidator nhận được Spring bean qua constructor, còn constraint cấp class nên gắn lỗi vào một field bằng addPropertyNode. Ngoài web layer, service có @ValidatedValidator được inject áp dụng cùng các quy tắc đó.

Thứ còn thiếu là một response mà client dùng được: mọi lỗi trong bài này đều trả về cùng một body bốn field. Bài tiếp theo gom việc đó về một chỗ: xử lý exception tập trung với @RestControllerAdvice@ExceptionHandler, error response theo chuẩn ProblemDetail, và 422 cho body không qua được validation, theo thiết kế status code trong bài về HTTP và REST.

Bài viết liên quan

[Spring Boot Basics] Tài liệu API trong Spring Boot với springdoc-openapi và Swagger UI

springdoc-openapi 3.1.1 trên Spring Boot 4.1.1, kiểm chứng trên jar đang chạy: document OpenAPI 3.1 ở /v3/api-docs, Swagger UI và Try it out, những gì springdoc suy ra từ controller, DTO record và Bean Validation constraint, response nào của @RestControllerAdvice được thêm vào, @Tag, @Operation, @ApiResponse, @Parameter và @Schema trên record, bean OpenAPI và customizer toàn cục, GroupedOpenApi, property của springdoc và tắt tài liệu trong profile prod.

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Logging trong Spring Boot 4.1.1 kiểm chứng trên project thật: SLF4J là facade và Logback 1.5.38 là implementation, hai bridge jul-to-slf4j và log4j-to-slf4j, parameterised và fluent logging, ghi log exception, log level, cây logger và log group, --debug so với --trace, pattern dòng log mặc định, logging.file.name kèm rotation, logback-spring.xml với springProfile, MDC và chuyển sang Log4j2.

[Spring Boot Basics] @ConfigurationProperties trong Spring Boot: cấu hình type-safe kết hợp validation

Cấu hình type-safe trong Spring Boot 4.1.1 với @ConfigurationProperties, kiểm chứng bằng các lần chạy thật: bind vào record không cần @ConstructorBinding, JavaBean binding và @DefaultValue, ba cách đăng ký properties class, object lồng nhau, list, map, enum, chuyển đổi Duration và DataSize, relaxed binding và cách đặt tên environment variable, lỗi khởi động fail-fast với @Validated, metadata từ configuration processor, và bảng so sánh với @Value.

[Spring Boot Basics] Auto-configuration trong Spring Boot hoạt động ra sao: conditional, back-off và báo cáo --debug

Mổ xẻ cơ chế auto-configuration của Spring Boot 4.1.1 bằng số liệu thật: @EnableAutoConfiguration và AutoConfigurationImportSelector, các file META-INF/spring/…AutoConfiguration.imports mà Boot 4 tách ra nhiều module nhỏ, họ annotation @ConditionalOnClass / @ConditionalOnMissingBean cùng một Condition tự viết, màn demo back-off có số liệu trước và sau, và cách đọc báo cáo CONDITIONS EVALUATION REPORT từ --debug.