Giới thiệu
Một API chuyên nghiệp cần validate dữ liệu đầu vào, trả về lỗi với format chuẩn, và cấu hình khác nhau cho mỗi môi trường. Bài này sử dụng Bean Validation (Hibernate Validator), Exception Mappers theo chuẩn RFC 9457 Problem Details, và Config Profiles của Quarkus.
Bean Validation
Dependency
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
Validation trên DTO
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
public record CreateProductRequest(
@NotBlank(message = "Tên sản phẩm không được để trống")
@Size(min = 3, max = 255,
message = "Tên sản phẩm phải từ 3 đến 255 ký tự")
String name,
@Size(max = 5000, message = "Mô tả tối đa 5000 ký tự")
String description,
@NotNull(message = "Giá không được để trống")
@DecimalMin(value = "0.01", message = "Giá phải lớn hơn 0")
@DecimalMax(value = "999999999.99",
message = "Giá tối đa 999,999,999.99")
BigDecimal price,
@NotNull(message = "Danh mục không được để trống")
Long categoryId,
@Min(value = 0, message = "Số lượng không được âm")
@Max(value = 1000000, message = "Số lượng tối đa 1,000,000")
int stockQuantity
) {}
Validation trong Resource
import jakarta.validation.Valid;
@POST
public Response create(@Valid CreateProductRequest request) {
// Nếu validation fail → Quarkus tự động throw
// ConstraintViolationException với HTTP 400
ProductDTO created = productService.create(request);
return Response.created(
URI.create("/api/v1/products/" + created.id()))
.entity(created).build();
}
Custom Validator
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidSlug.Validator.class)
public @interface ValidSlug {
String message() default "Slug không hợp lệ (chỉ chấp nhận a-z, 0-9, dấu gạch ngang)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class Validator implements ConstraintValidator<ValidSlug, String> {
private static final Pattern SLUG_PATTERN =
Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
@Override
public boolean isValid(String value,
ConstraintValidatorContext ctx) {
if (value == null) return true; // @NotNull xử lý riêng
return SLUG_PATTERN.matcher(value).matches();
}
}
}
Cross-field Validation (Class-level)
Validate nhiều field phụ thuộc nhau:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy =
ValidDateRange.Validator.class)
public @interface ValidDateRange {
String message() default
"Ngày kết thúc phải sau ngày bắt đầu";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class Validator implements
ConstraintValidator<ValidDateRange,
CreatePromotionRequest> {
@Override
public boolean isValid(
CreatePromotionRequest req,
ConstraintValidatorContext ctx) {
if (req.startDate() == null
|| req.endDate() == null) {
return true;
}
if (req.endDate().isBefore(req.startDate())) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(
"endDate phải sau startDate")
.addPropertyNode("endDate")
.addConstraintViolation();
return false;
}
return true;
}
}
}
@ValidDateRange
public record CreatePromotionRequest(
@NotBlank String name,
@NotNull @DecimalMin("0.01") BigDecimal discount,
@NotNull @FutureOrPresent LocalDate startDate,
@NotNull @Future LocalDate endDate
) {}
Validation Groups
Dùng groups để validate khác nhau cho Create vs Update:
// Groups
public interface OnCreate {}
public interface OnUpdate {}
public record ProductRequest(
@Null(groups = OnCreate.class,
message = "Không được truyền ID khi tạo mới")
@NotNull(groups = OnUpdate.class,
message = "ID bắt buộc khi cập nhật")
Long id,
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
String name,
@NotNull(groups = OnCreate.class)
BigDecimal price,
@NotNull(groups = OnCreate.class)
@Min(value = 0, groups = {OnCreate.class, OnUpdate.class})
Integer stockQuantity
) {}
// Resource sử dụng groups
@POST
public Response create(
@Valid @ConvertGroup(to = OnCreate.class)
ProductRequest request) {
// ...
}
@PUT @Path("/{id}")
public ProductDTO update(
@PathParam("id") Long id,
@Valid @ConvertGroup(to = OnUpdate.class)
ProductRequest request) {
// ...
}
Method-level Validation
Validate parameters và return value:
@ApplicationScoped
public class ProductService {
@Inject
ProductRepository productRepo;
// Validate parameter
public List<Product> search(
@NotBlank String keyword,
@Min(0) int page,
@Min(1) @Max(100) int size) {
return productRepo.search(keyword,
Page.of(page, size));
}
// Validate return value
@Valid
public ProductDTO getById(@Min(1) Long id) {
return productRepo.findByIdOptional(id)
.map(ProductDTO::from)
.orElseThrow(() ->
new ResourceNotFoundException(
"Product", id));
}
}
Exception Handling — RFC 9457 Problem Details
Problem Details DTO
import java.net.URI;
import java.time.Instant;
import java.util.List;
public record ProblemDetail(
URI type,
String title,
int status,
String detail,
URI instance,
Instant timestamp,
List<FieldError> errors
) {
public record FieldError(
String field,
String message,
Object rejectedValue
) {}
public static ProblemDetail of(int status, String title,
String detail) {
return new ProblemDetail(
URI.create("about:blank"), title, status, detail,
null, Instant.now(), null);
}
public static ProblemDetail withErrors(int status, String title,
String detail,
List<FieldError> errors) {
return new ProblemDetail(
URI.create("about:blank"), title, status, detail,
null, Instant.now(), errors);
}
}
ConstraintViolation ExceptionMapper
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import java.util.List;
@Provider
public class ConstraintViolationExceptionMapper
implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException ex) {
List<ProblemDetail.FieldError> fieldErrors = ex
.getConstraintViolations()
.stream()
.map(cv -> new ProblemDetail.FieldError(
extractFieldName(cv),
cv.getMessage(),
cv.getInvalidValue()))
.toList();
ProblemDetail problem = ProblemDetail.withErrors(
400,
"Validation Error",
"Dữ liệu đầu vào không hợp lệ",
fieldErrors);
return Response.status(400)
.type("application/problem+json")
.entity(problem)
.build();
}
private String extractFieldName(ConstraintViolation<?> cv) {
String path = cv.getPropertyPath().toString();
// "create.request.name" → "name"
int lastDot = path.lastIndexOf('.');
return lastDot >= 0 ? path.substring(lastDot + 1) : path;
}
}
Custom Business Exceptions
// Exception classes
public class ResourceNotFoundException extends RuntimeException {
private final String resourceType;
private final Object resourceId;
public ResourceNotFoundException(String type, Object id) {
super(type + " with id " + id + " not found");
this.resourceType = type;
this.resourceId = id;
}
// getters...
}
public class BusinessException extends RuntimeException {
private final int statusCode;
public BusinessException(int statusCode, String message) {
super(message);
this.statusCode = statusCode;
}
// getter...
}
// ExceptionMapper
@Provider
public class ResourceNotFoundExceptionMapper
implements ExceptionMapper<ResourceNotFoundException> {
@Override
public Response toResponse(ResourceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.of(
404, "Resource Not Found", ex.getMessage());
return Response.status(404)
.type("application/problem+json")
.entity(problem).build();
}
}
@Provider
public class BusinessExceptionMapper
implements ExceptionMapper<BusinessException> {
@Override
public Response toResponse(BusinessException ex) {
ProblemDetail problem = ProblemDetail.of(
ex.getStatusCode(), "Business Error", ex.getMessage());
return Response.status(ex.getStatusCode())
.type("application/problem+json")
.entity(problem).build();
}
}
Catch-all ExceptionMapper
@Provider
public class GenericExceptionMapper
implements ExceptionMapper<Exception> {
private static final Logger LOG =
Logger.getLogger(GenericExceptionMapper.class);
@Override
public Response toResponse(Exception ex) {
LOG.error("Unhandled exception", ex);
ProblemDetail problem = ProblemDetail.of(
500,
"Internal Server Error",
"Đã xảy ra lỗi hệ thống. Vui lòng thử lại sau.");
return Response.status(500)
.type("application/problem+json")
.entity(problem).build();
}
}
Tổ chức ExceptionMappers — Centralized Handler
Thay vì nhiều mapper classes riêng lẻ, gom vào 1 class:
@Provider
public class GlobalExceptionHandler
implements ExceptionMapper<Throwable> {
private static final Logger LOG =
Logger.getLogger(GlobalExceptionHandler.class);
@Override
public Response toResponse(Throwable ex) {
return switch (ex) {
case ConstraintViolationException cve ->
handleValidation(cve);
case ResourceNotFoundException rnfe ->
handleNotFound(rnfe);
case BusinessException be ->
handleBusiness(be);
case ForbiddenException fe ->
handleForbidden(fe);
case WebApplicationException wae ->
handleWebApp(wae);
default -> handleUnexpected(ex);
};
}
private Response handleValidation(
ConstraintViolationException ex) {
List<ProblemDetail.FieldError> fieldErrors =
ex.getConstraintViolations().stream()
.map(cv -> new ProblemDetail.FieldError(
extractFieldName(cv),
cv.getMessage(),
cv.getInvalidValue()))
.toList();
return respond(400, "Validation Error",
"Dữ liệu đầu vào không hợp lệ",
fieldErrors);
}
private Response handleNotFound(
ResourceNotFoundException ex) {
return respond(404, "Not Found",
ex.getMessage(), null);
}
private Response handleBusiness(BusinessException ex) {
return respond(ex.getStatusCode(),
"Business Error", ex.getMessage(), null);
}
private Response handleForbidden(ForbiddenException ex) {
return respond(403, "Forbidden",
ex.getMessage(), null);
}
private Response handleWebApp(
WebApplicationException ex) {
int status = ex.getResponse().getStatus();
return respond(status,
Response.Status.fromStatusCode(status)
.getReasonPhrase(),
ex.getMessage(), null);
}
private Response handleUnexpected(Throwable ex) {
LOG.error("Unhandled exception", ex);
return respond(500, "Internal Server Error",
"Đã xảy ra lỗi hệ thống."
+ " Vui lòng thử lại sau.", null);
}
private Response respond(int status, String title,
String detail,
List<ProblemDetail.FieldError> errors) {
ProblemDetail problem = errors != null
? ProblemDetail.withErrors(
status, title, detail, errors)
: ProblemDetail.of(status, title, detail);
return Response.status(status)
.type("application/problem+json")
.entity(problem).build();
}
private String extractFieldName(
ConstraintViolation<?> cv) {
String path = cv.getPropertyPath().toString();
int lastDot = path.lastIndexOf('.');
return lastDot >= 0
? path.substring(lastDot + 1) : path;
}
}
Error Response i18n
Quarkus hỗ trợ validation messages đa ngôn ngữ:
# src/main/resources/ValidationMessages.properties
# (default — Vietnamese)
product.name.required=Tên sản phẩm không được để trống
product.name.size=Tên sản phẩm phải từ {min} đến {max} ký tự
product.price.min=Giá phải lớn hơn {value}
# src/main/resources/ValidationMessages_en.properties
product.name.required=Product name is required
product.name.size=Product name must be between {min} and {max} characters
product.price.min=Price must be greater than {value}
public record CreateProductRequest(
@NotBlank(message = "{product.name.required}")
@Size(min = 3, max = 255,
message = "{product.name.size}")
String name,
@DecimalMin(value = "0.01",
message = "{product.price.min}")
BigDecimal price
) {}
Client gửi header Accept-Language: en → nhận error messages tiếng Anh.
Response mẫu
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "Dữ liệu đầu vào không hợp lệ",
"timestamp": "2026-04-15T10:30:00Z",
"errors": [
{
"field": "name",
"message": "Tên sản phẩm không được để trống",
"rejectedValue": null
},
{
"field": "price",
"message": "Giá phải lớn hơn 0",
"rejectedValue": -100
}
]
}
Config Profiles
Mặc định: dev, test, prod
# application.properties
# Chung cho tất cả profiles
quarkus.application.name=product-service
quarkus.http.port=8080
# --- DEV profile (mặc định khi `quarkus dev`) ---
%dev.quarkus.log.level=DEBUG
%dev.quarkus.hibernate-orm.database.generation=drop-and-create
%dev.quarkus.hibernate-orm.log.sql=true
# --- TEST profile (mặc định khi `mvn test`) ---
%test.quarkus.log.level=INFO
%test.quarkus.hibernate-orm.database.generation=drop-and-create
# --- PROD profile (mặc định khi `java -jar`) ---
%prod.quarkus.log.level=WARN
%prod.quarkus.hibernate-orm.database.generation=none
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}
%prod.quarkus.datasource.username=${DB_USERNAME}
%prod.quarkus.datasource.password=${DB_PASSWORD}
Custom Profiles
# Profile: staging
%staging.quarkus.datasource.jdbc.url=jdbc:postgresql://staging-db:5432/ecommerce
%staging.quarkus.log.level=INFO
%staging.quarkus.hibernate-orm.database.generation=none
# Chạy với custom profile
quarkus dev -Dquarkus.profile=staging
# hoặc
java -Dquarkus.profile=staging -jar target/quarkus-app/quarkus-run.jar
Application YAML (thay thế properties)
# src/main/resources/application.yml
quarkus:
application:
name: product-service
datasource:
db-kind: postgresql
hibernate-orm:
database:
generation: drop-and-create
"%prod":
quarkus:
datasource:
jdbc:
url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hibernate-orm:
database:
generation: none
Typesafe Config với @ConfigMapping
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
import java.util.Optional;
@ConfigMapping(prefix = "app.product")
public interface ProductConfig {
@WithDefault("20")
int defaultPageSize();
@WithDefault("100")
int maxPageSize();
Optional<String> importFilePath();
FeatureFlags features();
interface FeatureFlags {
@WithDefault("false")
boolean enableRecommendations();
@WithDefault("true")
boolean enableFullTextSearch();
}
}
# application.properties
app.product.default-page-size=20
app.product.max-page-size=100
app.product.features.enable-recommendations=false
app.product.features.enable-full-text-search=true
@ApplicationScoped
public class ProductService {
@Inject
ProductConfig config;
public List<Product> list(int page, int size) {
int pageSize = Math.min(size, config.maxPageSize());
// ...
}
}
Bài tập
- Thêm Bean Validation cho
CreateProductRequestvàUpdateProductRequest - Tạo custom
@ValidSlugannotation validator - Implement cross-field validation
@ValidDateRangecho promotion - Implement
ProblemDetailresponse theo RFC 9457 - Tạo
GlobalExceptionHandlerxử lý tất cả exceptions với pattern matching - Thêm Validation Groups (
OnCreate,OnUpdate) cho ProductRequest - Tạo
ValidationMessages.propertiescho i18n (Vietnamese & English) - Cấu hình
%dev,%test,%prodprofiles trongapplication.properties - Tạo
ProductConfigvới@ConfigMappingđể quản lý typed config - Viết
@QuarkusTesttest validation errors trả về đúng Problem Details format
Testing Validation
@QuarkusTest
class ValidationTest {
@Test
void testCreateProductValidation() {
given()
.contentType("application/json")
.body("""
{
"name": "",
"price": -100,
"stockQuantity": -1
}
""")
.when().post("/api/v1/products")
.then()
.statusCode(400)
.contentType("application/problem+json")
.body("title", equalTo("Validation Error"))
.body("status", equalTo(400))
.body("errors.size()", greaterThan(0))
.body("errors.field",
hasItems("name", "price"));
}
@Test
void testNotFoundResponse() {
given()
.when().get("/api/v1/products/99999")
.then()
.statusCode(404)
.contentType("application/problem+json")
.body("title", equalTo("Not Found"));
}
@Test
void testCustomSlugValidation() {
given()
.contentType("application/json")
.body("""
{ "slug": "Invalid Slug With Spaces!" }
""")
.when().post("/api/v1/categories")
.then()
.statusCode(400)
.body("errors[0].field",
equalTo("slug"));
}
}
Tổng kết
- Bean Validation (
@NotBlank,@Size,@Min...) tự động validate khi dùng@Valid - ExceptionMapper chuyển đổi exceptions thành JSON response chuẩn
- RFC 9457 Problem Details — format lỗi chuẩn với
application/problem+json - Config Profiles (
%dev,%test,%prod) — cấu hình riêng cho mỗi môi trường @ConfigMapping— typesafe configuration thay thế@ConfigProperty
Bài tiếp theo: Domain-Driven Design & Database per Service — thiết kế microservices architecture.