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.

Cùng một DTO đó, hai lời gọi cho hai tập constraint được chạy khác nhau:

Một record OrderRequest có ba constraint rẽ thành hai nhánh: nhánh @Valid chạy nhóm Default nên chỉ chạy @Positive total và bỏ qua hai constraint khai groups, nhánh @Validated Create chạy @Null id và @NotBlank customer nhưng bỏ qua @Positive total nên total âm lọt qua

@Positive không khai groups nên nó thuộc nhóm Default, mà @Validated(Create.class) chỉ chạy nhóm Create — constraint tưởng như luôn có hiệu lực lại chính là constraint bị bỏ qua.

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 khai @Constraint(validatedBy = …), validator class hiện thực ConstraintValidator, và chỗ dùng trên field DTO.

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ó đưa message qua MessageInterpolator: chuỗi được tra trong messages.properties theo Locale của request, nên cùng một key sinh ra câu tiếng Việt hay tiếng Anh tuỳ người gọi.

Hai loại ngoặc nhọn trông giống nhau nhưng khác vai: {order.customer.size}message key để tra file properties, còn {min} / {max}constraint attribute placeholder — Hibernate Validator tự thay bằng giá trị min / max khai trên @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: Kafka consumer, job định kỳ import CSV, service khác gọi nội bộ. Ba đường sau không đi qua controller nào — 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:

Bốn hộp nét đứt bên trái là bốn đường gọi vào service gồm HTTP controller có @Valid, Kafka consumer, job định kỳ import CSV và service khác gọi nội bộ, cả bốn cùng đi vào một hộp cao là AOP proxy quanh OrderService, từ đó rẽ thành nhánh ném ConstraintViolationException và nhánh method chạy với dữ liệu sạch

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). Điểm đáng giá là vị trí: proxy nằm ở chỗ cả bốn đường gọi đều phải đi qua, nên rule chỉ cần viết một lần.

@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
0/5 câu đã trả lời
  1. 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.
  2. 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. 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?
  4. Q4
    Message '{order.customer.size}' trong @Size(min=2, max=100) được resolve ra sao? Placeholder '{min}''{max}' đến từ đâu?
  5. 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?

Bài tiếp theo: OpenAPI & springdoc

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Bài tiếp theo

OpenAPI & springdoc-openapi — auto-generate API doc từ controller