Chuyển đến nội dung chính

Lesson 5: Validation, Error Handling & Config Profiles

Bean Validation with Hibernate Validator, Exception Mappers, Problem Details (RFC 9457), Config Profiles for dev/staging/prod environments.

💻 Programming — Lesson 4 Lesson 5: Validation, Error Handling & Config Profiles

Quarkus Microservices: From Basics to Production

Part 1: Quarkus Platform & Project Setup

xdev.asia

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

  1. Add Bean Validation for CreateProductRequest and UpdateProductRequest
  2. Create custom @ValidSlug annotation validator
  3. Implement cross-field validation @ValidDateRange for promotions
  4. Implement ProblemDetail response according to RFC 9457
  5. Create GlobalExceptionHandler Handle all exceptions with pattern matching
  6. Add Validation Groups (OnCreate, OnUpdate) for ProductRequest
  7. Create ValidationMessages.properties for i18n (Vietnamese & English)
  8. Configuration %dev, %test, %prod profiles in application.properties
  9. Create ProductConfig with @ConfigMapping to manage typed config
  10. Write @QuarkusTest test 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.