Spring REST API & Data JPA/Validation nâng cao — Groups, Custom Constraint, i18n, Service Layer
17/46
Bài 17 / 46~13 phútError, Validation & API DocsMiễn phí lượt xem

Validation nâng cao — Groups, Custom Constraint, i18n, Service Layer

Bài atomic đào sâu 4 cơ chế validation nâng cao của Jakarta Bean Validation: validation groups để tái dùng DTO cho nhiều ngữ cảnh (Create/Update), custom @Constraint để đóng gói rule domain, i18n message qua messages.properties, và validation trong service layer qua @Validated. Giải thích tại sao mỗi cơ chế tồn tại và khi nào dùng.

TL;DR: Bốn cơ chế nâng cao của Jakarta Bean Validation giải quyết 4 bài toán khác nhau: (1) Validation groups — cùng một DTO, rule khác nhau tùy ngữ cảnh POST/PUT, dùng marker interface Create/Update + @Validated(Create.class); (2) Custom @Constraint — rule domain không nằm trong 23 built-in, viết annotation + ConstraintValidator class; (3) i18n message — message lỗi dịch theo locale qua messages.properties + placeholder {min} / {max}; (4) @Validated service layer — validate ở boundary service khi service được gọi từ nhiều entry point (Kafka, scheduler) ngoài controller. Hiểu cơ chế bên dưới (AOP proxy, ConstraintValidatorContext) giúp tránh silent-fail và anti-pattern inject DB vào validator.

Bài trước (Validation constraints cơ bản) giới thiệu 23 built-in constraint và @Valid cascade. Bài này đào sâu 4 cơ chế nâng cao — mỗi cơ chế giải quyết một nhóm bài toán thực tế mà built-in không đủ.

1. Validation groups — tại sao một DTO cho nhiều ngữ cảnh

Bài toán thực tế

API POST /orders (tạo mới) và PUT /orders/{id} (cập nhật) xử lý cùng kiểu dữ liệu nhưng có rule khác nhau:

  • POST: id phải null (server auto-gen), total bắt buộc.
  • PUT: id phải not null (chỉ định bản ghi cần sửa), total tuỳ chọn (chỉ update field nào gửi lên).

Không có validation groups, bạn có 2 lựa chọn tệ: (a) viết 2 DTO riêng với field gần giống nhau, hoặc (b) để rule chung nhất rồi validate thủ công trong controller. Cả 2 đều giảm signal từ annotation và tăng code noise.

Cơ chế bên dưới — marker interface + group selection

Jakarta Bean Validation định nghĩa mỗi constraint có thuộc tính groups — một mảng class interface đóng vai trò nhãn. Khi bạn gọi @Validated(Create.class), Spring truyền nhãn đó xuống Validator.validate(object, Create.class), và Hibernate Validator chỉ evaluate những constraint nào khai báo groups = Create.class. Constraint không khai groups thuộc default group — apply cho mọi lần validate.

// Marker interface — chi can ton tai, khong can method
public interface Create {}
public interface Update {}
public record OrderRequest(

    @Null(groups = Create.class)           // POST: server tu gen, client khong gui
    @NotNull(groups = Update.class)        // PUT: phai co de xac dinh ban ghi
    Long id,

    @NotBlank(groups = {Create.class, Update.class})
    @Size(min = 2, max = 100, groups = {Create.class, Update.class})
    String customer,

    @NotNull(groups = Create.class)        // POST: bat buoc
    @Positive(groups = {Create.class, Update.class})
    BigDecimal total                       // PUT: optional (null = giu nguyen)
) {}
@RestController
@RequestMapping("/api/orders")
public class OrderController {

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

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

@Validated(Create.class) là annotation của Spring — khác @Valid Jakarta (không support group). Spring truyền class group xuống Hibernate Validator qua MethodValidationInterceptor.

Vì sao nhìn lại thường chọn 2 DTO riêng

Groups giải quyết được bài toán, nhưng có trade-off:

Khía cạnhValidation groups2 DTO riêng
Số class1 DTO + 2 marker interface2 DTO
OpenAPI schema1 schema khó express "field optional tuỳ verb"2 schema rõ ràng
Type safetyService nhận 1 type, phải defensive-check nullService biết chắc kiểu dữ liệu
Đọc constraintPhải trace từng groups = attributeĐọc field trực tiếp
@Valid standardKhông dùng được — phải dùng @Validated SpringDùng @Valid Jakarta chuẩn

Quy tắc thực tế: dùng 2 DTO riêng cho 90% case (POST vs PUT khác cấu trúc). Dùng validation groups khi có 5+ scenario (Draft, Review, Publish, Archive, ...) — quá nhiều DTO gây explosion.

Pitfall: constraint không khai groups

Constraint không khai groups attribute thuộc Default group và apply cho mọi lần validate(). Khi gọi @Validated(Create.class), constraint thuộc Default group không apply — chỉ Create.class apply. Nếu muốn constraint chạy cho cả 2, phải khai rõ cả 2: groups = {Create.class, Update.class}.

2. Custom @Constraint — khi built-in không đủ

Tại sao cần constraint custom

23 built-in constraint của Jakarta Bean Validation xử lý rule syntactic (format, range, null). Nhưng domain rule của bạn thường semantic — không nằm ở format mà ở nghĩa nghiệp vụ:

  • SKU phải match pattern riêng của công ty: ABC-1234 (3 chữ hoa + gạch + 4 số).
  • Ngày giao hàng phải sau ngày đặt ít nhất 1 ngày.
  • Mã voucher phải thuộc danh sách active (database check — nhưng xem pitfall §2.3).

Rule này không dùng @Pattern + hard-code regex trong annotation được vì: (a) thiếu tên ngữ nghĩa, (b) không reusable, (c) message không đủ mô tả.

Cơ chế bên dưới — 3 phần tạo custom constraint

Custom constraint gồm 3 phần: annotation + validator class + sử dụng.

flowchart LR
    A["@ValidSku annotation<br/>@Constraint(validatedBy=...)"] --> B["SkuValidator<br/>ConstraintValidator&lt;ValidSku, String&gt;"]
    B --> C["isValid(value, ctx)<br/>return true/false"]
    D["OrderItem DTO"] -- "@ValidSku String sku" --> A

Bước 1 — Annotation:

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuValidator.class)
@Documented
public @interface ValidSku {
    String message() default "Invalid SKU format (expected: ABC-1234)";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    // 3 attribute groups/payload/message la bat buoc theo JSR 380
}

Bước 2 — Validator class:

public class SkuValidator implements ConstraintValidator<ValidSku, String> {

    private static final Pattern SKU_PATTERN =
        Pattern.compile("^[A-Z]{3}-\\d{4}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        if (value == null) return true;     // null la job cua @NotNull, khong phai ta
        return SKU_PATTERN.matcher(value).matches();
    }
}

Bước 3 — Sử dụng:

public record OrderItem(
    @NotBlank
    @ValidSku               // domain rule
    String sku,

    @Min(1) @Max(99)
    int quantity,

    @NotNull @Positive
    BigDecimal unitPrice
) {}

Cross-field constraint — validate nhiều field cùng lúc

Khi rule liên quan nhiều hơn 1 field (vd: ngày bắt đầu trước ngày kết thúc), đặt annotation ở class/record level thay field level:

@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, OrderRequest> {

    @Override
    public boolean isValid(OrderRequest req, ConstraintValidatorContext ctx) {
        if (req.orderDate() == null || req.deliveryDate() == null) return true;
        boolean valid = !req.deliveryDate().isBefore(req.orderDate().plusDays(1));

        if (!valid) {
            // Gan loi vao field cu the thay vi object-level
            ctx.disableDefaultConstraintViolation();
            ctx.buildConstraintViolationWithTemplate(
                "Delivery date must be at least 1 day after order date"
            ).addPropertyNode("deliveryDate").addConstraintViolation();
        }
        return valid;
    }
}

@ValidDateRange   // dat tren class/record
public record OrderRequest(
    LocalDate orderDate,
    LocalDate deliveryDate,
    // ... other fields
) {}

Dùng ConstraintValidatorContext.addPropertyNode("deliveryDate") để gán lỗi vào field cụ thể — client nhận được violations[].field = "deliveryDate" thay vì lỗi ở object root.

Cho validation cross-field đơn giản hơn (dùng 1 lần), @AssertTrue method ngắn gọn hơn:

public record OrderRequest(LocalDate orderDate, LocalDate deliveryDate) {

    @AssertTrue(message = "Delivery must be at least 1 day after order date")
    public boolean isDeliveryDateValid() {
        if (orderDate == null || deliveryDate == null) return true;
        return !deliveryDate.isBefore(orderDate.plusDays(1));
    }
}

@AssertTrue trên method is*() — Hibernate Validator invoke method, expect true là valid. Không cần viết annotation riêng.

Pitfall: inject DB vào validator — anti-pattern

Lỗi thường gặp là inject @Repository hoặc @Service vào ConstraintValidator để check database (vd kiểm tra SKU tồn tại). Việc này tạo coupling annotation validation vào DB và phá single responsibility. Hơn nữa, validator có thể được khởi tạo ngoài Spring context (unit test, standalone library) — bean inject là null.

Quy tắc: ConstraintValidatorpure function — chỉ check format/structure, không gọi DB hay external service. Business validation (SKU tồn tại trong DB, voucher còn hiệu lực) là trách nhiệm của service layer, throw domain exception.

3. i18n message — lỗi trả về đúng ngôn ngữ người dùng

Tại sao cần i18n message

Hardcode message "Customer name is required" trong annotation có 3 vấn đề: (a) không thể dịch theo locale của user, (b) khó maintain khi message cần thay đổi — phải sửa annotation ở nhiều chỗ, (c) không tận dụng placeholder constraint ({min}, {max}).

Cơ chế bên dưới — interpolation pipeline

Khi Hibernate Validator tạo violation message, nó qua pipeline interpolation:

flowchart LR
    A["message = '{order.customer.size}'"] --> B["MessageInterpolator<br/>tra messages.properties<br/>theo Locale"]
    B --> C["'Customer name must be<br/>between 2 and 100 chars'"]
    B --> D["'Ten khach hang phai<br/>dai tu 2 den 100 ky tu'"]

{order.customer.size} — curly braces là message key. {min}, {max}constraint attribute placeholder — Hibernate Validator tự thay bằng giá trị min / max của @Size.

Cài đặt:

// DTO dung message key
public record OrderRequest(

    @NotBlank(message = "{order.customer.required}")
    @Size(min = 2, max = 100, message = "{order.customer.size}")
    String customer,

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

    @NotNull(message = "{order.delivery.required}")
    @Future(message = "{order.delivery.future}")
    LocalDate deliveryDate
) {}

src/main/resources/messages.properties (default — tiếng Việt nếu app Vietnamese-first):

order.customer.required=Ten khach hang khong duoc trong
order.customer.size=Ten khach hang phai dai tu {min} den {max} ky tu
order.total.required=Tong tien la bat buoc
order.total.positive=Tong tien phai lon hon 0
order.delivery.required=Ngay giao hang la bat buoc
order.delivery.future=Ngay giao hang phai trong tuong lai

src/main/resources/messages_en.properties (tiếng Anh):

order.customer.required=Customer name is required
order.customer.size=Customer name must be between {min} and {max} characters
order.total.required=Total amount is required
order.total.positive=Total amount must be positive
order.delivery.required=Delivery date is required
order.delivery.future=Delivery date must be in the future

Spring Boot đọc messages.properties tự động qua MessageSource autoconfig. Locale được resolve theo Accept-Language header của request:

@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;
    }
}

Request với Accept-Language: en nhận English message. Accept-Language: vi nhận Vietnamese. Thiếu header hoặc locale không được support, Spring rơi về default vi.

Verify key ton tai trong moi locale

Missing key trong 1 locale khiến user thấy raw key (order.customer.required thay tên field) — UX tệ. Thêm CI check:

diff <(grep -oP '^[\w.]+(?==)' src/main/resources/messages.properties | sort) \
     <(grep -oP '^[\w.]+(?==)' src/main/resources/messages_en.properties | sort)
# Output khac -> build fail -> bat missing translation

4. Validation trong service layer — tại sao cần thêm một tầng

Vấn đề với chỉ validate ở controller

Controller validate request từ HTTP. Nhưng service thường được gọi từ nhiều entry point hơn:

flowchart TB
    A["HTTP Controller<br/>@Valid @RequestBody"] --> S["OrderService"]
    B["Kafka Consumer<br/>deserialize JSON"] --> S
    C["Scheduled Job<br/>CSV import"] --> S
    D["Internal API / RPC"] --> S
    S --> DB[(Database)]

Path B, C, D không đi qua controller — không có @Valid guard. Nếu service không tự validate, invalid data có thể vào DB từ path không phải HTTP.

Cơ chế bên dưới — AOP proxy

@Validated trên service class kích hoạt MethodValidationPostProcessor (Boot autoconfig). Post-processor wrap service bean trong một AOP proxy. Khi code gọi orderService.create(req), lời gọi thực ra đi qua proxy:

flowchart LR
    Caller["Caller (Controller / Kafka)"] --> Proxy["AOP Proxy<br/>MethodValidationInterceptor"]
    Proxy -- "constraint OK" --> Svc["OrderService.create(req)"]
    Proxy -- "constraint fail" --> Ex["throw ConstraintViolationException"]

MethodValidationInterceptor.invoke() gọi Validator.validate() trên các tham số có annotation constraint trước khi forward xuống method thật. Nếu có vi phạm, interceptor throw ConstraintViolationException (khác với MethodArgumentNotValidException của controller).

@Service
@Validated                  // bat AOP proxy cho method validation
public class OrderService {

    public OrderDto create(@Valid OrderRequest req) {
        // @Valid cascade validate OrderRequest + nested
        // Neu fail: ConstraintViolationException truoc khi vao method body
        return orderRepository.save(toEntity(req));
    }

    public OrderDto findById(@NotNull @Positive Long id) {
        // Parameter constraint truc tiep (khong can @Valid)
        return orderRepository.findById(id)
            .map(this::toDto)
            .orElseThrow(() -> new ResourceNotFoundException("Order", id));
    }
}

Map ConstraintViolationException thành response

Service throw ConstraintViolationException — cần map sang HTTP 400 trong @RestControllerAdvice:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ConstraintViolationException.class)
    public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setDetail("One or more parameters did not pass validation");

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

Khi nào nên / không nên validate service

Validate service có overhead (AOP proxy mỗi method call ~10-50µs). Không phải lúc nào cũng cần:

Tình huốngValidate service?Lý do
Service chỉ được gọi từ controllerKhông cầnController đã guard
Service được gọi từ Kafka consumerCầnConsumer không qua controller
Service được gọi từ scheduled jobCầnScheduled job không qua controller
Service expose qua RPC / shared libraryCầnCaller external không trust
Hot path vượt 10k QPSCân nhắcOverhead nhỏ nhưng cộng dồn

Quy tắc: validate tại boundary — nơi input đến từ bên ngoài. Service là boundary khi có path ngoài controller.

5. Liên hệ các bài khác

  • Validation constraints cơ bản: 23 built-in constraint, @Valid cascade, MethodArgumentNotValidException — nền tảng trước khi đọc bài này.
  • OpenAPI và springdoc: validation annotation (@NotNull, @Size, @Pattern) được springdoc đọc và tự động sinh schema OpenAPI — constraint trên DTO trở thành documentation sống. Bài đó giải thích cơ chế mapping.
  • Bài 01 — Error handling và ProblemDetail: ConstraintViolationException từ service layer cần map trong @RestControllerAdvice — xem pattern xử lý tập trung ở bài đó.
  • Bài 02 — @ExceptionHandlerGlobalExceptionHandler: nơi đặt handler cho cả MethodArgumentNotValidException lẫn ConstraintViolationException.

Tóm tắt

  • Validation groups dùng marker interface (Create, Update) + @Validated(Create.class) để apply rule khác nhau cho cùng DTO theo ngữ cảnh. Constraint không khai groups thuộc Default group — không apply khi chỉ định group cụ thể.
  • Prefer 2 DTO riêng (CreateRequest + UpdateRequest) hơn groups trong 90% case — type safety tốt hơn, OpenAPI schema rõ hơn, dùng @Valid Jakarta chuẩn.
  • Custom @Constraint gồm 3 phần: annotation (@Constraint(validatedBy=...)), validator class (ConstraintValidator<A, T>), sử dụng. Validator là pure function — không gọi DB hay external service.
  • Cross-field: annotation đặt ở class-level, validator nhận toàn object. @AssertTrue method is*() là cách đơn giản hơn cho rule 1-shot.
  • i18n message: dùng key {order.customer.required} thay hardcode string, resolve qua messages.properties theo locale Accept-Language. Placeholder {min} / {max} tự được Hibernate Validator substitute.
  • Service layer @Validated: kích hoạt AOP proxy, validate method parameter trước khi vào method body. Throw ConstraintViolationException. Cần khi service là boundary của path ngoài controller (Kafka, scheduler, RPC).

Tự kiểm tra

Tự kiểm tra
Q1
Cùng một OrderRequest DTO, field id phải null khi POST và not-null khi PUT. Dùng validation groups, viết annotation đúng cho field id và controller method signature tương ứng.

Khai báo 2 marker interface và annotate field id với cả 2 constraint, mỗi cái thuộc 1 group:

public interface Create {}
public interface Update {}

public record OrderRequest(
  @Null(groups = Create.class)      // POST: server auto-gen, client gui null
  @NotNull(groups = Update.class)   // PUT: bat buoc de xac dinh ban ghi
  Long id,
  // ... other fields
) {}

Controller dùng @Validated(Create.class) / @Validated(Update.class) — không phải @Valid@Valid Jakarta không support group:

@PostMapping
public OrderDto create(
      @Validated(Create.class) @RequestBody OrderRequest req) { ... }

@PutMapping("/{id}")
public OrderDto update(
      @PathVariable Long id,
      @Validated(Update.class) @RequestBody OrderRequest req) { ... }

Khi POST: Hibernate Validator chỉ evaluate constraint thuộc Create.class — field id phải null. Khi PUT: chỉ evaluate Update.class — field id phải not-null. Constraint không khai groups thuộc Default group và không apply khi gọi @Validated(Create.class) — chỉ groups chỉ định mới chạy.

Q2
Viết custom @ValidSku constraint cho field String sku: SKU phải match pattern ABC-1234 (3 chữ hoa + dấu gạch + 4 chữ số). Liệt kê 3 phần cần viết và pitfall null-handling.

3 phần:

1. Annotation — khai báo @Constraint(validatedBy=...) + 3 attribute bắt buộc theo JSR 380:

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SkuValidator.class)
public @interface ValidSku {
  String message() default "Invalid SKU (expected: ABC-1234)";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
}

2. Validator class — implement ConstraintValidator<ValidSku, String>:

public class SkuValidator implements ConstraintValidator<ValidSku, String> {

  private static final Pattern SKU_PATTERN =
      Pattern.compile("^[A-Z]{3}-\d{4}$");

  @Override
  public boolean isValid(String value, ConstraintValidatorContext ctx) {
      if (value == null) return true;   // null la job cua @NotNull
      return SKU_PATTERN.matcher(value).matches();
  }
}

3. Sử dụng:

public record OrderItem(
  @NotBlank @ValidSku String sku,
  @Min(1) int quantity
) {}

Pitfall null-handling: Pattern.matcher(null) throw NullPointerException. Rule: validator luôn return true khi value là null — để @NotNull (constraint riêng biệt) handle null check. Tách trách nhiệm giúp constraint compose độc lập.

Q3
Giải thích tại sao không nên inject @Repository vào ConstraintValidator để kiểm tra SKU tồn tại trong DB. Nên xử lý loại validation này ở đâu?

Tại sao không inject DB vào validator — 3 lý do:

  1. Lifecycle không đảm bảo: ConstraintValidator có thể được khởi tạo trước Spring context hoàn tất (vd khi validate entity trong Hibernate lifecycle). Bean inject là null → NullPointerException khó trace.
  2. Không reusable ngoài Spring: Unit test, standalone tool, hay library dùng lại validator mà không có Spring context → injection fail.
  3. Phá single responsibility: Annotation validator chỉ nên kiểm tra format/structure. Business rule "SKU phải tồn tại" là domain logic, không phải validation syntactic.

Cách đúng — tách 2 lớp:

// Annotation: chi check format (pure, no DI)
@NotBlank @ValidSku String sku   // "ABC-1234" format

// Service: check existence (business rule)
@Service
public class OrderService {
  public OrderDto create(@Valid OrderRequest req) {
      for (var item : req.items()) {
          if (!skuRepository.existsById(item.sku())) {
              throw new SkuNotFoundException(item.sku());  // domain exception
          }
      }
      // ...
  }
}

Annotation validator là pure function, không có side effect, không phụ thuộc infrastructure. Business validation ném domain exception — caller biết đây là rule nghiệp vụ, không phải format error.

Q4
Message '{order.customer.size}' trong @Size(min=2, max=100) được resolve ra sao? Placeholder '{min}''{max}' đến từ đâu?

Pipeline interpolation của Hibernate Validator:

  1. Hibernate Validator đọc message attribute của constraint: '{order.customer.size}' — nhận ra đây là message key (curly braces, không phải literal).
  2. MessageInterpolator tra messages.properties theo locale của request (Accept-Language header → LocaleResolver). Tìm key order.customer.size.
  3. Giá trị tìm được: Customer name must be between {min} and {max} characters.
  4. Hibernate Validator substitute {min}{max} bằng attribute của constraint: @Size(min=2, max=100)min=2, max=100.
  5. Kết quả: Customer name must be between 2 and 100 characters.

Điểm quan trọng: {min} / {max} là **attribute placeholder** của constraint — Hibernate Validator tự inject, không cần khai trong messages.properties. Mỗi constraint có set placeholder riêng (@Pattern{regexp}, @Min{value}...).

Spring Boot autoconfig MessageSource để đọc messages.properties từ classpath. Nếu thiếu key → Hibernate Validator trả raw key làm message — đây là bug cần CI check bắt sớm.

Q5
Service OrderService annotate @Validated. Cơ chế nào khiến constraint trên method parameter được validate? Exception nào throw khi fail — và tại sao khác với exception từ controller?

Cơ chế — AOP proxy:

Khi Spring Boot thấy @Validated trên class, MethodValidationPostProcessor (được ValidationAutoConfiguration register) wrap service bean trong một AOP proxy. Mọi lời gọi method đi qua MethodValidationInterceptor trước khi forward xuống method thật:

Caller -> AOP Proxy (MethodValidationInterceptor)
         -> Validator.validate(params, groups)
         -> constraints OK? -> method body
         -> constraints fail? -> throw ConstraintViolationException

Lưu ý self-call: nếu method trong cùng class gọi method khác trong cùng class (this.findById(id)), lời gọi đi trực tiếp, không qua proxy — constraint không trigger. Đây là AOP proxy limitation chung.

Tại sao exception khác controller:

  • Controller: Spring MVC bind + validate @RequestBody trước khi vào method, throw MethodArgumentNotValidException (Spring MVC exception, có BindingResult).
  • Service: AOP interceptor validate method parameter, throw ConstraintViolationException (Jakarta Bean Validation exception, có Set<ConstraintViolation<?>>).

Cả 2 đều cần handler riêng trong @RestControllerAdvice — không dùng chung được vì API của 2 exception khác nhau.

Bài tiếp theo: OpenAPI & springdoc

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

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

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên