Spring Boot/Validation — Jakarta Bean Validation, custom validator, cross-field
~22 phútREST API với Spring MVCMiễn phí

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

AnnotationApplyDescription
@NotNullObjectField không null
@NullObjectField phải null (rare)

2.2 String / CharSequence

AnnotationApplyDescription
@NotBlankStringKhông null, không empty, không chỉ whitespace
@NotEmptyString, Collection, Map, ArrayKhông null, không empty
@Size(min, max)String, Collection, ArrayLength trong range
@Pattern(regexp)StringMatch regex
@EmailStringValid email format

2.3 Number

AnnotationApplyDescription
@Min(value)Number≥ value
@Max(value)Number≤ value
@DecimalMin(value)Number/String≥ value (BigDecimal precision)
@DecimalMax(value)Number/String≤ value
@PositiveNumber> 0
@PositiveOrZeroNumber≥ 0
@NegativeNumber< 0
@NegativeOrZeroNumber≤ 0
@Digits(integer, fraction)NumberSố digit tối đa

2.4 Date / Time

AnnotationApplyDescription
@PastDate, LocalDate, InstantTrong quá khứ
@PastOrPresentDateQuá khứ hoặc hiện tại
@FutureDateTrong tương lai
@FutureOrPresentDateTương lai hoặc hiện tại

2.5 Boolean / Generic

AnnotationApplyDescription
@AssertTrueboolean= true
@AssertFalseboolean= false
@ValidNested objectCascade 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
SourceJakarta Bean Validation (JSR 380)Spring's annotation
ScopeMethod parameter, field cascadeClass-level (bật method validation)
Group supportKhôngCó (@Validated(Create.class))
ThrowMethodArgumentNotValidException (controller), ConstraintViolationException (cascade)ConstraintViolationException

Quy tắc thực tế:

  • @Valid trên parameter/field → trigger validation.
  • @Validated trê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 email fail rate spike → frontend regex stale.
  • Field total Min violation 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:

  1. Check @Valid trên @RequestBody?
  2. Check @Validated class-level cho @RequestParam?
  3. Boot 3 — spring-boot-starter-validation trong pom?
  4. 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:

ChangeBreaking?Reason
Add @NotNull cho field optional cũYESOld client gửi null → fail
Remove @NotNullNORelax constraint
Tighten @Size(max=100) xuống @Size(max=50)YESOld data invalid
Loosen @Size(max=50) lên @Size(max=100)NOAdditive
Change @Pattern regex stricterYESOld data invalid
Add new @Pattern cho field cũ không cóYESOld data invalid
Add new validator groupNOAdditive
Remove validator groupYESBehavior 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:#fef3c7

3 layer defense:

  1. Controller @Valid — entry point web/mobile.
  2. Service @Validated — entry point Kafka/scheduler/library.
  3. 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

📚 Tài liệu chính chủ

JSR / Jakarta:

Spring:

Pattern:

Source:

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-validation riê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.
  • @Valid vs @Validated: Valid = trigger, Validated = enable infrastructure + groups.
  • @RequestParam constraint cần @Validated trê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 @AssertTrue method.
  • Localized message qua messages.properties + placeholder \{min\}, \{max\}.
  • Validation trong service layer với @Validated class-level — defense-in-depth. Throw ConstraintViolationException (khác MethodArgumentNotValidException của controller).
  • Validator bean inject để validate ad-hoc (CSV import, Kafka message).
  • Production pattern: validate ở boundary (controller, message consumer). Service trust input đã clean.

14. Tự kiểm tra

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
) {}

3 vấn đề:

  1. Quên @Valid trên @RequestBody:
    public OrderDto create(@RequestBody OrderRequest req)    // KHONG validate
    // Fix:
    public OrderDto create(@Valid @RequestBody OrderRequest req)
    Không có @Valid → mọi constraint trên OrderRequest (NotBlank, NotNull, Positive) skip. Silent fail.
  2. Quên @Validated trên controller class:
    @RestController                         // THIEU
    @Validated                              // CAN
    public class OrderController { ... }
    Không có @Validated → constraint trên @RequestParam (@Min, @Email) silently skip. Spring không trigger method-level validation cho parameter constraint.
  3. Quên @Valid cho cascade:
    public record OrderRequest(
      ...
      @Valid List<OrderItem> items     // CAN @Valid de cascade
    ) {}
    Không có @Valid trên list → constraint trên OrderItem (@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.

Q2
So 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:

AspectValidation groups2 DTO tách biệt
Class count1 DTO + 2 marker interface2 DTO
OpenAPI doc1 schema, khó express group2 schema rõ ràng — Create vs Update
Field differenceAnnotation phức tạp (groups attribute)Field structure khác nhau natural
Validation typePhải dùng @Validated (Spring), không @Valid@Valid standard Jakarta
Refactor fieldSửa annotation groupsSửa class tương ứng
Type safety1 type — service phải check field nullable2 type — IDE check explicit
Code lineCompactVerbose
ReadabilityKhó — phải đọc groups attributeDễ — 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 CreateOrderRequest biết chắc customer không null. Không phải req.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.

Q3
Implement validation: order item phải có quantity * unitPrice == totaldeliveryDate > 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ặc has*() theo JavaBean convention.
  • Return true nếu valid, false nếu fail.
  • Null-safe — return true nếu field null (skip — let @NotNull handle).
  • 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:

ScenarioApproach
Validation 1 lần, không reuse@AssertTrue
Cùng logic dùng nhiều DTOCustom annotation
Cần error message chi tiết với placeholderCustom 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).

Q4
Validation trong service layer (@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:

  1. Service được gọi từ nhiều entry point:
    @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));
    }
    Service validate guarantee mọi path đều check.
  2. Public library / SDK: service exposed qua API public — không trust caller validate đúng.
  3. 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: @Valid trên @RequestBody + @Validated class cho @RequestParam.
  • Service: NO @Validated mặc định.
  • Add @Validated service 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).

Q5
Custom 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);
  }
}
  1. Inject SkuService vào validator: work nếu Bean Validation provider Hibernate Validator được Spring tích hợp (Boot autoconfig làm điều đó qua SpringConstraintValidatorFactory). 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.
    Fix: tách 2 validation:
    // 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());
          }
          // ...
      }
    }
    Quy tắc: annotation validator = pure, no DB/external call. Business validation = service layer.
  2. 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:
    public boolean isValid(String value, ConstraintValidatorContext context) {
      if (value == null) return true;       // null is job of @NotNull, not us
      return SKU_PATTERN.matcher(value).matches();
    }
    Quy tắc: custom validator return true cho null. @NotNull separate 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 true nếu null.
Q6
Boot 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?

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:

  1. Check pom.xml dependencies:
    mvn dependency:tree | grep -i validation
    Nếu không có hibernate-validator hoặc jakarta.validation-api → thiếu dependency.
  2. Check Actuator /actuator/conditions:
    curl http://localhost:8080/actuator/conditions | jq '.contexts.application.negativeMatches.ValidationAutoConfiguration'
    Nếu ValidationAutoConfiguration trong negativeMatches với reason "Validator class not found" → thiếu validator.
  3. Check log startup: Boot 3 không error nếu validation deps thiếu — silent skip. Đó là intentional (validation optional).
  4. Verify by sending invalid request:
    curl -X POST http://localhost:8080/api/orders \
      -H "Content-Type: application/json" \
      -d '{"customer":"","total":-10}'
    Expect 400 với violation detail. Nếu server save record với invalid data → confirm validation skip.

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...