Validation — Jakarta Bean Validation, custom validator, cross-field
Jakarta Bean Validation 3.0 chuẩn JSR 380. Bài này bóc 23 built-in constraint, @Valid cascade, custom @Constraint annotation, cross-field validation, validation groups (Create/Update), validation runtime trong service layer, và pattern fail-fast.
Bài 03 đã giới thiệu @Valid @RequestBody. Bài này đào sâu Jakarta Bean Validation — chuẩn JSR 380 mà Spring tích hợp. Sau bài này, bạn không bao giờ viết validation manual if (x == null || x.isBlank()) trong controller — declarative annotation handle tất cả.
Validation là 1 trong 5 trụ cột clean REST API: bind → validate → service → response → error. Lỡ validation fail-fast = production bug runtime.
1. Jakarta Bean Validation — chuẩn JSR
Jakarta Bean Validation (trước: JSR 380, javax.validation) — chuẩn Java cho declarative validation. Hibernate Validator là implementation tham chiếu, đi cùng spring-boot-starter-validation.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
(Boot 3+ tách validation khỏi starter-web — phải add explicit nếu cần.)
2. 23 built-in constraint
Bảng đầy đủ constraint của Jakarta Bean Validation 3:
2.1 Null check
| Annotation | Apply | Description |
|---|---|---|
@NotNull | Object | Field không null |
@Null | Object | Field phải null (rare) |
2.2 String / CharSequence
| Annotation | Apply | Description |
|---|---|---|
@NotBlank | String | Không null, không empty, không chỉ whitespace |
@NotEmpty | String, Collection, Map, Array | Không null, không empty |
@Size(min, max) | String, Collection, Array | Length trong range |
@Pattern(regexp) | String | Match regex |
@Email | String | Valid email format |
2.3 Number
| Annotation | Apply | Description |
|---|---|---|
@Min(value) | Number | ≥ value |
@Max(value) | Number | ≤ value |
@DecimalMin(value) | Number/String | ≥ value (BigDecimal precision) |
@DecimalMax(value) | Number/String | ≤ value |
@Positive | Number | > 0 |
@PositiveOrZero | Number | ≥ 0 |
@Negative | Number | < 0 |
@NegativeOrZero | Number | ≤ 0 |
@Digits(integer, fraction) | Number | Số digit tối đa |
2.4 Date / Time
| Annotation | Apply | Description |
|---|---|---|
@Past | Date, LocalDate, Instant | Trong quá khứ |
@PastOrPresent | Date | Quá khứ hoặc hiện tại |
@Future | Date | Trong tương lai |
@FutureOrPresent | Date | Tương lai hoặc hiện tại |
2.5 Boolean / Generic
| Annotation | Apply | Description |
|---|---|---|
@AssertTrue | boolean | = true |
@AssertFalse | boolean | = false |
@Valid | Nested object | Cascade validation |
2.6 Ví dụ tổng hợp
public record OrderRequest(
@NotBlank(message = "Customer name is required")
@Size(min = 2, max = 100)
String customer,
@NotBlank
@Email
String email,
@NotNull
@Positive
@DecimalMax("999999.99")
BigDecimal total,
@NotNull
@Future(message = "Delivery date must be in future")
LocalDate deliveryDate,
@NotEmpty
@Size(max = 100, message = "Maximum 100 items per order")
@Valid // cascade — validate each OrderItem
List<OrderItem> items,
@Pattern(regexp = "^[A-Z]{2}\\d{4}$", message = "Voucher must be 2 letters + 4 digits")
String voucherCode
) {}
public record OrderItem(
@NotBlank String sku,
@Min(1) @Max(99) int quantity,
@NotNull @Positive BigDecimal unitPrice
) {}
Constraint gắn trên record component — Spring tự pick up qua @Valid.
3. @Valid cascade
@Valid annotation trigger validation. Đặt ở 3 vị trí:
3.1 Trên @RequestBody
@PostMapping("/orders")
public OrderDto create(@Valid @RequestBody OrderRequest req) { ... }
Validate OrderRequest + cascade vào nested object có @Valid:
public record OrderRequest(
...
@Valid List<OrderItem> items // CASCADE
) {}
Nested OrderItem cũng được validate. Không có @Valid cascade → constraint trên OrderItem skip.
3.2 Trên path/query parameter
@GetMapping("/orders")
public List<OrderDto> list(
@RequestParam @Min(0) int page,
@RequestParam @Min(1) @Max(100) int size,
@RequestParam @Email String filterEmail
) { ... }
Cảnh báo: Constraint trên parameter cần annotate class controller với @Validated:
@RestController
@Validated // BAT VALIDATION CHO PARAM
@RequestMapping("/api/orders")
public class OrderController { ... }
Không có @Validated class-level → constraint trên @RequestParam không trigger.
3.3 Trên service method (cross-controller validation)
@Service
@Validated // bat method-level validation
public class OrderService {
public OrderDto create(@Valid OrderRequest req) {
// Spring intercept method call qua AOP, validate truoc khi enter
}
public OrderDto findById(@NotNull @Positive Long id) {
// ID phai positive, khong null
}
}
@Validated trên service class trigger AOP proxy. Mọi method call qua proxy validate parameter trước khi enter method body.
Constraint fail → throw ConstraintViolationException (khác với MethodArgumentNotValidException của controller).
4. Difference: @Valid vs @Validated
| Aspect | @Valid | @Validated |
|---|---|---|
| Source | Jakarta Bean Validation (JSR 380) | Spring's annotation |
| Scope | Method parameter, field cascade | Class-level (bật method validation) |
| Group support | Không | Có (@Validated(Create.class)) |
| Throw | MethodArgumentNotValidException (controller), ConstraintViolationException (cascade) | ConstraintViolationException |
Quy tắc thực tế:
@Validtrên parameter/field → trigger validation.@Validatedtrên class → bật infrastructure (AOP for service, method validation for controller).
5. Validation groups — Create vs Update
Pattern phổ biến: cùng DTO, validation rules khác nhau giữa POST (Create) vs PUT (Update).
public interface Create {}
public interface Update {}
public record OrderRequest(
@Null(groups = Create.class) // Create: phai null (auto-gen)
@NotNull(groups = Update.class) // Update: phai có
Long id,
@NotBlank(groups = {Create.class, Update.class})
String customer,
@NotNull(groups = Create.class) // Create require
BigDecimal total // Update optional
) {}
@PostMapping("/orders")
public OrderDto create(@Validated(Create.class) @RequestBody OrderRequest req) { ... }
@PutMapping("/orders/{id}")
public OrderDto update(@PathVariable Long id, @Validated(Update.class) @RequestBody OrderRequest req) { ... }
@Validated(Create.class) chỉ apply constraint với groups = Create.class. Constraint không khai groups → default group, apply mọi @Validated.
Cảnh báo: dùng @Validated (Spring) thay @Valid khi cần group. @Valid không support group.
Pattern này phức tạp — nhiều team prefer 2 DTO khác nhau cho Create/Update:
public record CreateOrderRequest(@NotBlank String customer, @NotNull BigDecimal total) {}
public record UpdateOrderRequest(@NotNull Long id, String customer, BigDecimal total) {}
Đơn giản hơn, không cần group. Verbose hơn nhưng cleaner.
6. Custom @Constraint
Khi built-in không đủ, tự define annotation custom:
6.1 Validate single field
// 1. Annotation
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuValidator.class)
public @interface ValidSku {
String message() default "Invalid SKU format";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 2. Validator
public class SkuValidator implements ConstraintValidator<ValidSku, String> {
private static final Pattern SKU_PATTERN = Pattern.compile("^[A-Z]{3}-\\d{4}$");
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // null check by @NotNull, not here
return SKU_PATTERN.matcher(value).matches();
}
}
// 3. Use
public record OrderItem(
@NotBlank @ValidSku String sku,
@Min(1) int quantity
) {}
3 phần: annotation + validator class + use. Pattern này extensible — add domain rule custom mà không pollute DTO.
6.2 Validate cross-field (whole object)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "Start date must be before end date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, OrderFilter> {
public boolean isValid(OrderFilter filter, ConstraintValidatorContext context) {
if (filter.from() == null || filter.to() == null) return true;
return !filter.from().isAfter(filter.to());
}
}
@ValidDateRange
public record OrderFilter(
LocalDate from,
LocalDate to
) {}
Annotation đặt trên class/record (không field) → validator nhận toàn object → check relationship giữa field.
Hoặc đơn giản hơn — @AssertTrue method:
public record OrderFilter(LocalDate from, LocalDate to) {
@AssertTrue(message = "from must be before to")
public boolean isDateRangeValid() {
if (from == null || to == null) return true;
return !from.isAfter(to);
}
}
@AssertTrue trên method is*() — Bean Validation invoke method, expect true. Đơn giản hơn custom annotation cho 1-shot validation.
7. Localized error message
Default message trong annotation. Message với placeholder:
@Size(min = 2, max = 100, message = "{order.customer.size}")
String customer;
{order.customer.size} resolve qua messages.properties:
# src/main/resources/messages.properties
order.customer.size=Customer name must be between {min} and {max} characters
order.customer.size.vi=Tên khách hàng phải dài từ {min} đến {max} ký tự
Spring Boot tự config MessageSource đọc messages.properties. Locale theo Accept-Language header.
\{min\}, \{max\} là parameter của constraint — Bean Validation substitute tự động.
8. Validation trong service layer
Validation không chỉ ở controller. Service method validate parameter cho safety:
@Service
@Validated
public class OrderService {
public OrderDto findById(@NotNull @Positive Long id) {
// Goi tu controller, library, scheduler — luon validate
return repo.findById(id).orElseThrow(...);
}
public List<OrderDto> findByCustomer(@NotBlank @Email String email,
@NotNull Pageable page) {
// ...
}
}
Constraint fail trong service → throw ConstraintViolationException. Map qua advice:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
List<Map<String, String>> violations = ex.getConstraintViolations().stream()
.map(v -> Map.of(
"path", v.getPropertyPath().toString(),
"message", v.getMessage(),
"rejectedValue", String.valueOf(v.getInvalidValue())
))
.toList();
pd.setProperty("violations", violations);
return pd;
}
}
Khác MethodArgumentNotValidException (controller) — same Problem Details format, khác source.
9. Pattern thực tế
9.1 Validation order
Bean Validation không guarantee thứ tự constraint evaluate. Constraint nặng (regex, custom validator) chạy cùng lúc constraint nhẹ (@NotNull).
Optimization: dùng @GroupSequence để chạy theo order:
public interface Step1 {}
public interface Step2 {}
@GroupSequence({Step1.class, Step2.class})
public interface OrderedChecks {}
public record User(
@NotBlank(groups = Step1.class)
@Email(groups = Step2.class) // chi check Email neu Step1 pass
String email
) {}
// Use
@PostMapping("/users")
public ... create(@Validated(OrderedChecks.class) @RequestBody User u) { ... }
Pattern phức tạp — chỉ dùng khi performance critical (vd @Email regex chậm, skip nếu null).
9.2 Conditional validation
Constraint conditional — validate chỉ khi field khác có value cụ thể:
public record DiscountRequest(
@NotNull DiscountType type,
BigDecimal percentage,
BigDecimal fixedAmount
) {
@AssertTrue(message = "Percentage required for PERCENT discount")
public boolean isPercentageValid() {
return type != DiscountType.PERCENT || percentage != null;
}
@AssertTrue(message = "Fixed amount required for FIXED discount")
public boolean isFixedValid() {
return type != DiscountType.FIXED || fixedAmount != null;
}
}
Pattern: @AssertTrue method conditional. Logic: "if type X, then field Y required".
9.3 Validate qua Bean Validation API trực tiếp
Đôi khi cần validate ad-hoc:
@Service
public class OrderService {
private final Validator validator; // jakarta.validation.Validator
public void importBulk(List<OrderRequest> requests) {
for (var req : requests) {
Set<ConstraintViolation<OrderRequest>> violations = validator.validate(req);
if (!violations.isEmpty()) {
log.warn("Skipping invalid order: {}", violations);
continue;
}
create(req);
}
}
}
Validator bean có sẵn (Boot autoconfig) — inject + dùng API trực tiếp. Phù hợp khi validate non-controller input (file CSV, Kafka message, scheduled job).
10. Vận hành production — observability, regex DoS, i18n, schema versioning
Validation chạy mỗi request → là hot path performance + attack surface. Section này cover quy trình production: monitoring, regex DoS, i18n locale deployment, schema versioning.
10.1 Validation observability — track failure per field
Track validation failure rate per field — early warning UX bug:
@RestControllerAdvice
@Slf4j
public class ValidationMetricsAdvice {
private final MeterRegistry meterRegistry;
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handle(MethodArgumentNotValidException ex) {
ex.getBindingResult().getFieldErrors().forEach(err -> {
meterRegistry.counter("validation.failure",
"field", err.getField(),
"code", err.getCode(),
"objectName", err.getObjectName()
).increment();
});
ProblemDetail pd = ProblemDetail.forStatus(400);
pd.setTitle("Validation failed");
// ... build violations
return pd;
}
}
Metric validation.failure count theo (field, code) → dashboard:
- Field
emailfail rate spike → frontend regex stale. - Field
totalMinviolation cao → user confused về unit (cents vs dollars). - Field unknown fail nhiều → API contract đã đổi, chưa thông báo.
Alert anomaly:
- alert: ValidationFailureSpike
expr: rate(validation_failure_total[10m]) > 100
for: 10m
labels:
severity: warning
annotations:
summary: "Validation failure spike on field {{ $labels.field }}"
10.2 Regex DoS — catastrophic backtracking
Custom @Pattern regex có thể exploitable. Ví dụ kinh điển:
// VULNERABLE — catastrophic backtrack
@Pattern(regexp = "^([a-zA-Z]+)+$", message = "Letters only")
String input;
Input: aaaaaaaaaaaaaaaaaaa! (19 char + 1 invalid) → Java Matcher chạy O(2^n) → tốn 30s CPU 1 request → dễ DoS.
Test vulnerability trong CI:
@Test
void regex_completes_within_100ms_for_evil_input() {
String evilInput = "a".repeat(50) + "!";
Pattern pattern = Pattern.compile("^([a-zA-Z]+)+$");
long start = System.currentTimeMillis();
pattern.matcher(evilInput).matches();
long duration = System.currentTimeMillis() - start;
assertThat(duration).isLessThan(100); // bao ve regex DoS
}
Fix:
// SAFE — atomic group (?>...) khong backtrack
@Pattern(regexp = "^(?>[a-zA-Z]+)+$")
// SAFE hon — flat regex khong nested quantifier
@Pattern(regexp = "^[a-zA-Z]+$")
Tool quét: safe-regex, CodeQL rule js/redos. Add vào CI pipeline cho mọi PR thêm regex.
10.3 i18n message deployment — locale resolver
Production multi-locale: validation message phải localized theo Accept-Language của client.
Config locale resolver:
@Configuration
public class LocaleConfig {
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.forLanguageTag("vi"));
resolver.setSupportedLocales(List.of(
Locale.forLanguageTag("vi"),
Locale.forLanguageTag("en")
));
return resolver;
}
}
messages_vi.properties:
order.customer.required=Tên khách hàng không được trống
order.total.positive=Tổng tiền phải lớn hơn 0
order.delivery.future=Ngày giao phải trong tương lai
messages_en.properties:
order.customer.required=Customer name is required
order.total.positive=Total must be positive
order.delivery.future=Delivery date must be in the future
DTO sử dụng key:
public record OrderRequest(
@NotBlank(message = "{order.customer.required}")
String customer,
@Positive(message = "{order.total.positive}")
BigDecimal total,
@Future(message = "{order.delivery.future}")
LocalDate deliveryDate
) {}
Request Accept-Language: en → English message. vi → Vietnamese. Fallback vi (default).
CI lint check — verify mọi key có value cho mọi locale supported:
# Pseudo script — chay trong GH Actions
diff <(grep -oP '^[\w.]+' messages_vi.properties | sort) \
<(grep -oP '^[\w.]+' messages_en.properties | sort)
# Khac → build fail
Missing translation → user thấy raw key (order.customer.required thay tên field) → UX tệ.
10.4 Failure runbook — 4 mode validation
Mode 1 — Validation silent skip (invalid data save vào DB):
Triệu chứng: report user data có giá trị invalid (vd email không có @), constraint không catch.
Diagnose:
- Check
@Validtrên@RequestBody? - Check
@Validatedclass-level cho@RequestParam? - Boot 3 —
spring-boot-starter-validationtrong pom? mvn dependency:tree | grep hibernate-validator— present?
Remediate: add missing annotation/dependency. Add integration test verify validation fail trả 400.
Mode 2 — Validation failure spike từ 1 field cụ thể:
Diagnose: validation.failure metric label field — top field failing.
Investigate:
- Frontend regex/format đã đổi không sync backend? → align contract.
- API doc unclear → update OpenAPI schema example.
- Mobile app version cũ gửi format cũ → ramp validation gradually.
Mode 3 — Custom validator throw exception (trả 500 thay 400):
Triệu chứng: log "ConstraintValidator threw exception".
Diagnose: validator code không null-safe hoặc throw runtime exception.
Remediate: validator return true cho null (let @NotNull handle), wrap external call try/catch.
Mode 4 — Performance regression — validation chậm:
Triệu chứng: P99 endpoint tăng sau add custom validator.
Diagnose: validator có DB call hoặc external lookup (anti-pattern).
Remediate: tách business validation ra service layer (annotation = pure check). Cache validator state nếu cần.
10.5 Schema versioning — breaking vs additive
OpenAPI schema generate từ validation annotation. Đổi rule = đổi schema = break client.
Bảng compatibility:
| Change | Breaking? | Reason |
|---|---|---|
Add @NotNull cho field optional cũ | YES | Old client gửi null → fail |
Remove @NotNull | NO | Relax constraint |
Tighten @Size(max=100) xuống @Size(max=50) | YES | Old data invalid |
Loosen @Size(max=50) lên @Size(max=100) | NO | Additive |
Change @Pattern regex stricter | YES | Old data invalid |
Add new @Pattern cho field cũ không có | YES | Old data invalid |
| Add new validator group | NO | Additive |
| Remove validator group | YES | Behavior change |
Quy tắc: relax = safe, tighten = breaking. Tighten → bump API version (v1 → v2) hoặc add deprecation period.
10.6 Defense-in-depth — multi-boundary validation
Validation không chỉ ở controller. Production app có nhiều entry point — mỗi cái là 1 boundary cần check:
flowchart LR
Web[Web client] --> Ctrl[Controller @Valid]
Mobile[Mobile app] --> Ctrl
Kafka[Kafka consumer] --> Svc[Service @Validated]
Sched[Scheduled job] --> Svc
CSV[CSV import] --> Svc
Ctrl --> Svc
Svc --> DB[(DB constraint)]
style Ctrl fill:#fef3c7
style Svc fill:#fef3c7
style DB fill:#fef3c73 layer defense:
- Controller
@Valid— entry point web/mobile. - Service
@Validated— entry point Kafka/scheduler/library. - DB constraint (NOT NULL, CHECK, UNIQUE) — ultimate safety net.
Layer DB cuối cùng: nếu app bug bypass validation, DB throw DataIntegrityViolationException → 500 → bug visible. Pattern enterprise — không tin app code purely.
ALTER TABLE orders
ADD CONSTRAINT total_positive CHECK (total > 0),
ADD CONSTRAINT customer_not_blank CHECK (length(trim(customer)) > 0);
Migration Flyway thêm constraint này (Module 04 bài 06 đào sâu).
11. Pitfall tổng hợp
❌ Nhầm 1: Quên @Valid trên @RequestBody.
public OrderDto create(@RequestBody OrderRequest req) { ... } // KHONG validate
✅ Add @Valid. Constraint trên record không trigger nếu không annotate.
❌ Nhầm 2: Quên @Validated class-level cho @RequestParam validation.
@RestController // KHONG @Validated
public class C {
@GetMapping("/orders")
public ... list(@RequestParam @Min(0) int page) { ... } // @Min skip
}
✅ Add @Validated trên class hoặc method.
❌ Nhầm 3: Quên @Valid cho cascade nested.
public record OrderRequest(
List<OrderItem> items // KHONG cascade
) {}
Constraint trên OrderItem không trigger.
✅ @Valid List<OrderItem> items.
❌ Nhầm 4: Custom validator lỗi xử lý null.
public boolean isValid(String value, ...) {
return SKU_PATTERN.matcher(value).matches(); // NPE neu value = null
}
✅ Check null trước:
public boolean isValid(String value, ...) {
if (value == null) return true; // null la job cua @NotNull
return SKU_PATTERN.matcher(value).matches();
}
❌ Nhầm 5: Constraint message với hardcode tên parameter.
@NotBlank(message = "Customer is required") // hardcode
Khó i18n. Khó refactor.
✅ Dùng message key: message = "{order.customer.required}".
❌ Nhầm 6: Validation groups phức tạp. ✅ 2 DTO Create/Update tách biệt cho hầu hết case. Group chỉ khi 5+ scenario.
❌ Nhầm 7: Validate trong service ngay sau controller validate.
@PostMapping
public ... create(@Valid @RequestBody OrderRequest req) {
return service.create(req); // service @Validated lai validate lan nua → wasted
}
✅ Validate 1 lần. Service method có @Validated chỉ khi có path khác (background job, library) gọi.
12. 📚 Deep Dive Spring Reference
JSR / Jakarta:
- Jakarta Bean Validation 3.0 Spec — chính chủ.
- Hibernate Validator Reference — implementation tham chiếu.
Spring:
Pattern:
- Baeldung — Custom Constraint — viết custom annotation.
- Baeldung — Validation Groups — group + sequence.
Source:
MethodValidationInterceptor— AOP proxy validate method param.ValidationAutoConfiguration— Boot autoconfig.
Tool:
- IntelliJ "Java EE: Validations" inspection — highlight constraint missing.
- Spring Boot Actuator
/actuator/configprops— verify validation autoconfig active.
13. Tóm tắt
- Jakarta Bean Validation 3.0 (JSR 380) chuẩn Java cho declarative validation. Hibernate Validator implementation. Boot 3+ tách
starter-validationriêng. - 23 built-in constraint: null/string/number/date/boolean. Combine cho 90% case.
@Valid: trigger validation, cascade vào nested. Đặt trên parameter, field.@Validated: Spring's annotation. Class-level bật method validation (AOP proxy). Support validation groups.@Validvs@Validated: Valid = trigger, Validated = enable infrastructure + groups.@RequestParamconstraint cần@Validatedtrên controller class — nếu không, constraint silently skip.- Validation groups (
Create.class,Update.class) — same DTO, rule khác. Phức tạp — prefer 2 DTO tách biệt. - Custom
@Constraint= annotation + validator class. Cross-field qua class-level annotation hoặc@AssertTruemethod. - Localized message qua
messages.properties+ placeholder\{min\},\{max\}. - Validation trong service layer với
@Validatedclass-level — defense-in-depth. ThrowConstraintViolationException(khácMethodArgumentNotValidExceptioncủa controller). Validatorbean inject để validate ad-hoc (CSV import, Kafka message).- Production pattern: validate ở boundary (controller, message consumer). Service trust input đã clean.
14. Tự kiểm tra
Q1Đoạn sau có gì sai? Constraint nào silently skip?@RestController
public class OrderController {
@PostMapping("/orders")
public OrderDto create(@RequestBody OrderRequest req) {
return orderService.create(req);
}
@GetMapping("/orders")
public List<OrderDto> list(
@RequestParam @Min(0) int page,
@RequestParam @Email String filter
) { ... }
}
public record OrderRequest(
@NotBlank String customer,
@NotNull @Positive BigDecimal total,
List<OrderItem> items
) {}
public record OrderItem(
@NotBlank String sku,
@Min(1) int quantity
) {}
▸
@RestController
public class OrderController {
@PostMapping("/orders")
public OrderDto create(@RequestBody OrderRequest req) {
return orderService.create(req);
}
@GetMapping("/orders")
public List<OrderDto> list(
@RequestParam @Min(0) int page,
@RequestParam @Email String filter
) { ... }
}
public record OrderRequest(
@NotBlank String customer,
@NotNull @Positive BigDecimal total,
List<OrderItem> items
) {}
public record OrderItem(
@NotBlank String sku,
@Min(1) int quantity
) {}3 vấn đề:
- Quên
@Validtrên@RequestBody:Không cópublic OrderDto create(@RequestBody OrderRequest req) // KHONG validate // Fix: public OrderDto create(@Valid @RequestBody OrderRequest req)@Valid→ mọi constraint trênOrderRequest(NotBlank, NotNull, Positive) skip. Silent fail. - Quên
@Validatedtrên controller class:Không có@RestController // THIEU @Validated // CAN public class OrderController { ... }@Validated→ constraint trên@RequestParam(@Min,@Email) silently skip. Spring không trigger method-level validation cho parameter constraint. - Quên
@Validcho cascade:Không cópublic record OrderRequest( ... @Valid List<OrderItem> items // CAN @Valid de cascade ) {}@Validtrên list → constraint trênOrderItem(@NotBlank sku,@Min(1) quantity) skip cho mọi item trong list.
Code đúng:
@RestController
@Validated // BAT method validation
public class OrderController {
@PostMapping("/orders")
public OrderDto create(@Valid @RequestBody OrderRequest req) { ... }
@GetMapping("/orders")
public List<OrderDto> list(
@RequestParam @Min(0) int page,
@RequestParam @Email String filter
) { ... }
}
public record OrderRequest(
@NotBlank String customer,
@NotNull @Positive BigDecimal total,
@Valid List<OrderItem> items // CASCADE
) {}Bài học: validation silent fail là bug class. Test exception path (validation fail → 400) cho mọi DTO field. Coverage validation rules trong test giúp catch missing @Valid/@Validated.
Q2So sánh 2 cách handle Create vs Update khác validation rules: validation groups vs 2 DTO tách biệt. Recommend cái nào?▸
Cách 1 — Validation groups:
public interface Create {}
public interface Update {}
public record OrderRequest(
@Null(groups = Create.class)
@NotNull(groups = Update.class)
Long id,
@NotBlank(groups = {Create.class, Update.class})
String customer,
@NotNull(groups = Create.class)
BigDecimal total
) {}
@PostMapping("/orders")
public ... create(@Validated(Create.class) @RequestBody OrderRequest req) { ... }
@PutMapping("/orders/{id}")
public ... update(@Validated(Update.class) @RequestBody OrderRequest req) { ... }Cách 2 — 2 DTO tách biệt:
public record CreateOrderRequest(
@NotBlank String customer,
@NotNull @Positive BigDecimal total
) {}
public record UpdateOrderRequest(
@NotNull Long id,
String customer, // optional
BigDecimal total // optional
) {}
@PostMapping("/orders")
public ... create(@Valid @RequestBody CreateOrderRequest req) { ... }
@PutMapping("/orders/{id}")
public ... update(@Valid @RequestBody UpdateOrderRequest req) { ... }So sánh:
| Aspect | Validation groups | 2 DTO tách biệt |
|---|---|---|
| Class count | 1 DTO + 2 marker interface | 2 DTO |
| OpenAPI doc | 1 schema, khó express group | 2 schema rõ ràng — Create vs Update |
| Field difference | Annotation phức tạp (groups attribute) | Field structure khác nhau natural |
| Validation type | Phải dùng @Validated (Spring), không @Valid | @Valid standard Jakarta |
| Refactor field | Sửa annotation groups | Sửa class tương ứng |
| Type safety | 1 type — service phải check field nullable | 2 type — IDE check explicit |
| Code line | Compact | Verbose |
| Readability | Khó — phải đọc groups attribute | Dễ — schema explicit |
Recommend: 2 DTO tách biệt.
Lý do:
- OpenAPI doc rõ ràng: consumer xem doc thấy 2 schema — không phải đoán "khi POST thì field nào required".
- Type safety: service nhận
CreateOrderRequestbiết chắccustomerkhông null. Không phảireq.customer().orElseThrow()defensive code. - Verbose chấp nhận được: 5-10 dòng extra cho clarity. Refactor 1 field — 1 file.
- Standard
@Valid: Jakarta standard, dễ migrate giữa framework.
Khi nào groups OK:
- App có 5+ scenario validation (Create, Update, Patch, Draft, Final, ...) — quá nhiều DTO.
- Library shared — không control 2 DTO.
- Migration legacy code đã dùng groups.
Khoá này: 2 DTO tách biệt cho TaskFlow capstone.
Q3Implement validation: order item phải có quantity * unitPrice == total và deliveryDate > orderDate + 1 day. Cách viết?▸
quantity * unitPrice == total và deliveryDate > orderDate + 1 day. Cách viết?2 cách: @AssertTrue method (đơn giản) hoặc custom annotation (reusable).
Cách 1 — @AssertTrue method (recommend cho 1-shot):
public record OrderRequest(
@NotNull @Positive Integer quantity,
@NotNull @Positive BigDecimal unitPrice,
@NotNull @Positive BigDecimal total,
@NotNull LocalDate orderDate,
@NotNull LocalDate deliveryDate
) {
@AssertTrue(message = "Total must equal quantity * unitPrice")
public boolean isTotalValid() {
if (quantity == null || unitPrice == null || total == null) return true;
BigDecimal expected = unitPrice.multiply(BigDecimal.valueOf(quantity));
return total.compareTo(expected) == 0;
}
@AssertTrue(message = "Delivery date must be at least 1 day after order date")
public boolean isDeliveryDateValid() {
if (orderDate == null || deliveryDate == null) return true;
return deliveryDate.isAfter(orderDate.plusDays(0)); // strictly after
}
}Quy tắc @AssertTrue:
- Method tên
is*()hoặchas*()theo JavaBean convention. - Return
truenếu valid,falsenếu fail. - Null-safe — return
truenếu field null (skip — let@NotNullhandle). - Không có parameter (no-arg).
Cách 2 — Custom annotation (reusable):
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderTotalValidator.class)
public @interface ValidOrderTotal {
String message() default "Total inconsistent with line items";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class OrderTotalValidator implements ConstraintValidator<ValidOrderTotal, OrderRequest> {
public boolean isValid(OrderRequest req, ConstraintValidatorContext ctx) {
if (req.quantity() == null || req.unitPrice() == null || req.total() == null) {
return true;
}
BigDecimal expected = req.unitPrice().multiply(BigDecimal.valueOf(req.quantity()));
boolean valid = req.total().compareTo(expected) == 0;
if (!valid) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(
"Total " + req.total() + " != " + expected
).addPropertyNode("total").addConstraintViolation();
}
return valid;
}
}
@ValidOrderTotal
public record OrderRequest(...) {}Khi nào dùng cái nào:
| Scenario | Approach |
|---|---|
| Validation 1 lần, không reuse | @AssertTrue |
| Cùng logic dùng nhiều DTO | Custom annotation |
| Cần error message chi tiết với placeholder | Custom annotation (full ConstraintValidatorContext) |
| Cần config (parameter trong annotation) | Custom annotation |
Khoá này TaskFlow: bắt đầu với @AssertTrue cho cross-field validation đơn giản. Convert sang custom annotation khi rule shared (3+ DTO).
Q4Validation trong service layer (@Validated class) — khi nào nên dùng? Có duplicate với controller validation không?▸
@Validated class) — khi nào nên dùng? Có duplicate với controller validation không?Duplicate có, nhưng chấp nhận được trong nhiều case — defense-in-depth.
Khi nào nên dùng @Validated service:
- Service được gọi từ nhiều entry point:Service validate guarantee mọi path đều check.
@Service @Validated public class OrderService { public OrderDto create(@Valid OrderRequest req) { ... } } // Path 1: Controller @PostMapping public ... create(@Valid @RequestBody OrderRequest req) { return orderService.create(req); } // Path 2: Kafka consumer (no controller) @KafkaListener(topics = "orders") public void onOrder(String json) { OrderRequest req = mapper.readValue(json, OrderRequest.class); orderService.create(req); // KHONG qua @Valid controller — service tu validate } // Path 3: Scheduled job @Scheduled(...) public void importBulk() { csvReader.read().forEach(req -> orderService.create(req)); } - Public library / SDK: service exposed qua API public — không trust caller validate đúng.
- Defense-in-depth security: bug controller (quên
@Valid) → service catch. Multi-layer defense.
Khi nào KHÔNG cần:
- App đơn giản, chỉ controller gọi service: overhead AOP proxy + duplicate check không đáng. Controller validate đủ.
- Service phương thức internal (private/package-private): caller trong cùng module → trust.
- Service hot path (high QPS): AOP proxy có overhead nhỏ. Skip validation duplicate giúp throughput tốt hơn (rare).
Pattern recommended cho TaskFlow:
- Controller:
@Validtrên@RequestBody+@Validatedclass cho@RequestParam. - Service: NO
@Validatedmặc định. - Add
@Validatedservice nếu/khi:- Add Kafka consumer.
- Add scheduled job.
- Expose service qua RPC/library.
Performance impact: AOP proxy thêm ~10-50µs per call. Negligible cho 99% app. Chỉ measure nếu hot path (vượt 10k QPS) — likely vẫn OK.
Quy tắc: validate ở boundary (entry point external). Service layer chỉ validate khi service là boundary của 1 path khác (Kafka, library).
Q5Custom validator SkuValidator sau có 2 vấn đề. Sửa lỗi nào?public class SkuValidator implements ConstraintValidator<ValidSku, String> {
private final SkuService skuService; // (1)
@Autowired
public SkuValidator(SkuService skuService) {
this.skuService = skuService;
}
public boolean isValid(String value, ConstraintValidatorContext context) {
return SKU_PATTERN.matcher(value).matches() // (2)
&& skuService.exists(value);
}
}
▸
SkuValidator sau có 2 vấn đề. Sửa lỗi nào?public class SkuValidator implements ConstraintValidator<ValidSku, String> {
private final SkuService skuService; // (1)
@Autowired
public SkuValidator(SkuService skuService) {
this.skuService = skuService;
}
public boolean isValid(String value, ConstraintValidatorContext context) {
return SKU_PATTERN.matcher(value).matches() // (2)
&& skuService.exists(value);
}
}- Inject
SkuServicevào validator: work nếu Bean Validation provider Hibernate Validator được Spring tích hợp (Boot autoconfig làm điều đó quaSpringConstraintValidatorFactory). Nhưng nguy hiểm:- Validator có thể được init **trước** Spring context xong → bean null.
- Validator được dùng outside Spring context (vd unit test, library standalone) → fail.
- Coupling validator vào application service — phá single responsibility.
Quy tắc: annotation validator = pure, no DB/external call. Business validation = service layer.// Format check — pure annotation (no DI) public class SkuFormatValidator implements ConstraintValidator<ValidSkuFormat, String> { private static final Pattern SKU_PATTERN = Pattern.compile("^[A-Z]{3}-\\d{4}$"); public boolean isValid(String value, ConstraintValidatorContext ctx) { if (value == null) return true; return SKU_PATTERN.matcher(value).matches(); } } // Existence check — service layer business logic @Service public class OrderService { public OrderDto create(@Valid OrderRequest req) { // Format already validated by annotation // Existence check here — business logic, not annotation if (!skuService.exists(req.sku())) { throw new SkuNotFoundException(req.sku()); } // ... } } - NPE khi
value = null:public boolean isValid(String value, ConstraintValidatorContext context) { return SKU_PATTERN.matcher(value).matches(); // NPE neu value null }Pattern.matcher(null)throw NPE.Fix:Quy tắc: custom validator returnpublic boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) return true; // null is job of @NotNull, not us return SKU_PATTERN.matcher(value).matches(); }truecho null.@NotNullseparate constraint handle null check.
Use đúng:
public record OrderItem(
@NotBlank @ValidSkuFormat String sku, // 2 constraint independent
@Min(1) int quantity
) {}
@Service
@Validated
public class OrderService {
public OrderDto create(@Valid OrderRequest req) {
// 1. @Valid cascade — format validated by annotation
// 2. Business: check exist trong DB
for (var item : req.items()) {
if (!skuRepository.existsById(item.sku())) {
throw new SkuNotFoundException(item.sku());
}
}
return orderRepository.save(...);
}
}Bài học:
- Annotation validator = stateless, no DI, no external call.
- Business validation = service layer, throw domain exception.
- Null-safe:
return truenếu null.
Q6Boot 3 không pull spring-boot-starter-validation tự động. App của bạn dùng @Valid mà compile OK nhưng runtime không validate. Diagnose ra sao?▸
spring-boot-starter-validation tự động. App của bạn dùng @Valid mà compile OK nhưng runtime không validate. Diagnose ra sao?Cause: Boot 3 tách spring-boot-starter-validation riêng (không trong starter-web). Nếu bạn không add explicit dependency, classpath thiếu Hibernate Validator.
Diagnose qui trình:
- Check pom.xml dependencies:Nếu không có
mvn dependency:tree | grep -i validationhibernate-validatorhoặcjakarta.validation-api→ thiếu dependency. - Check Actuator
/actuator/conditions:Nếucurl http://localhost:8080/actuator/conditions | jq '.contexts.application.negativeMatches.ValidationAutoConfiguration'ValidationAutoConfigurationtrongnegativeMatchesvới reason "Validator class not found" → thiếu validator. - Check log startup: Boot 3 không error nếu validation deps thiếu — silent skip. Đó là intentional (validation optional).
- Verify by sending invalid request:Expect 400 với violation detail. Nếu server save record với invalid data → confirm validation skip.
curl -X POST http://localhost:8080/api/orders \ -H "Content-Type: application/json" \ -d '{"customer":"","total":-10}'
Fix:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Pull theo:
jakarta.validation:jakarta.validation-api— Bean Validation API.org.hibernate.validator:hibernate-validator— implementation.
Boot autoconfig ValidationAutoConfiguration sẽ register Validator bean + setup MethodValidationPostProcessor cho @Validated class.
Lý do Boot 3 tách:
- Validation không phải concern của mọi REST API — vd app proxy/gateway không validate body.
- Reduce default classpath size — micro-app không cần validation deps (~2MB).
- Force dev consciously add — không dependency lười.
Bài học: Boot 3 split starter philosophy — opt-in feature thay full-stack default. Add explicit dependency cho mỗi feature dùng.
Bài tiếp theo: OpenAPI/Swagger với springdoc-openapi — auto-generate API doc
Bài này có giúp bạn hiểu bản chất không?
Bình luận (0)
Đang tải...