Introduction
A professional API needs to validate input data, return errors with a standard format, and configure differently for each environment. This article uses Bean Validation (Hibernate Validator), Exception Mappers according to RFC 9457 Problem Details, and Config Profiles of Quarkus.
Bean Validation
Dependency
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
Validation on 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 in 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 multiple dependent fields:
@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
Use groups to validate differently for 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 and 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();
}
}
ExceptionMappers organization — Centralized Handler
Instead of many individual mapper classes, group them into one 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 supports multilingual validation messages:
# 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 sends headers Accept-Language: en → receive error messages in English.
Sample response
{
"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
Default: 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 (replace 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 with @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());
// ...
}
}
Exercises
- Add Bean Validation for
CreateProductRequestandUpdateProductRequest - Create custom
@ValidSlugannotation validator - Implement cross-field validation
@ValidDateRangefor promotions - Implement
ProblemDetailresponse according to RFC 9457 - Create
GlobalExceptionHandlerHandle all exceptions with pattern matching - Add Validation Groups (
OnCreate,OnUpdate) for ProductRequest - Create
ValidationMessages.propertiesfor i18n (Vietnamese & English) - Configuration
%dev,%test,%prodprofiles inapplication.properties - Create
ProductConfigwith@ConfigMappingto manage typed config - Write
@QuarkusTesttest validation errors returns the correct 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"));
}
}
Summary
- Bean Validation (
@NotBlank,@Size,@Min...) automatically validates when used@Valid - ExceptionMapper converts exceptions into standard JSON response
- RFC 9457 Problem Details — standard error format with
application/problem+json - Config Profiles (
%dev,%test,%prod) — separate configuration for each environment @ConfigMapping— typesafe configuration alternative@ConfigProperty
Next article: Domain-Driven Design & Database per Service — microservices architecture design.