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:idphải null (server auto-gen),totalbắt buộc.PUT:idphải not null (chỉ định bản ghi cần sửa),totaltuỳ 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:

@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ạnh | Validation groups | 2 DTO riêng |
|---|---|---|
| Số class | 1 DTO + 2 marker interface | 2 DTO |
| OpenAPI schema | 1 schema khó express "field optional tuỳ verb" | 2 schema rõ ràng |
| Type safety | Service nhận 1 type, phải defensive-check null | Service biết chắc kiểu dữ liệu |
| Đọc constraint | Phải trace từng groups = attribute | Đọc field trực tiếp |
@Valid standard | Không dùng được — phải dùng @Validated Spring | Dù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.
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.
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: ConstraintValidator là pure 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} là message key để tra file properties, còn {min} / {max} là 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.
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:

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ống | Validate service? | Lý do |
|---|---|---|
| Service chỉ được gọi từ controller | Không cần | Controller đã guard |
| Service được gọi từ Kafka consumer | Cần | Consumer không qua controller |
| Service được gọi từ scheduled job | Cần | Scheduled job không qua controller |
| Service expose qua RPC / shared library | Cần | Caller external không trust |
| Hot path vượt 10k QPS | Cân nhắc | Overhead 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,
@Validcascade,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:ConstraintViolationExceptiontừ service layer cần map trong@RestControllerAdvice— xem pattern xử lý tập trung ở bài đó. - Bài 02 —
@ExceptionHandlervàGlobalExceptionHandler: nơi đặt handler cho cảMethodArgumentNotValidExceptionlẫnConstraintViolationException.
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 khaigroupsthuộ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
@ValidJakarta chuẩn. - Custom
@Constraintgồ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.
@AssertTruemethodis*()là cách đơn giản hơn cho rule 1-shot. - i18n message: dùng key
{order.customer.required}thay hardcode string, resolve quamessages.propertiestheo localeAccept-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. ThrowConstraintViolationException. Cần khi service là boundary của path ngoài controller (Kafka, scheduler, RPC).
Tự kiểm tra
- Q1Cùng một
OrderRequestDTO, fieldidphải null khi POST và not-null khi PUT. Dùng validation groups, viết annotation đúng cho fieldidvà controller method signature tương ứng. - Q2Viết custom
@ValidSkuconstraint cho fieldString sku: SKU phải match patternABC-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. - Q3Giải thích tại sao không nên inject
@RepositoryvàoConstraintValidatorđể kiểm tra SKU tồn tại trong DB. Nên xử lý loại validation này ở đâu? - Q4Message
'{order.customer.size}'trong@Size(min=2, max=100)được resolve ra sao? Placeholder'{min}'và'{max}'đến từ đâu? - Q5Service
OrderServiceannotate@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
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