Master Hibernate Validator in Quarkus: Build Rock-Solid Validation for Your REST APIs
Learn how to add powerful, enterprise-grade validation to your Java applications: From simple annotations to custom business rules, all in one live-coded Quarkus project.
Validation is one of those things developers often only think about after something goes wrong. A missing @NotNull that leads to a NullPointerException. An unchecked email field that clogs your system with fake accounts. In enterprise applications, weak validation can become a costly liability.
Quarkus integrates Hibernate Validator, the reference implementation of the Jakarta Bean Validation specification, out of the box. That means you can enforce rules on JSON input, service methods, nested objects, collections, and even business-specific constraints, without pulling in extra libraries.
In this tutorial, we’ll build one application step by step. We’ll start with a simple User Registration API and evolve it into a realistic Order Management system with custom rules, nested validations, cross-field checks, and context-aware constraints. By the end, you’ll see validation as more than annotations. It is a guardrail for your entire system.
Keep quarkus dev running the whole time. We’ll alternate between curl requests to see live validation errors and JUnit tests to confirm correctness.
Bootstrapping the Project
Create a new Quarkus application with REST support:
quarkus create app org.acme:validator-demo:1.0.0 \
--no-code -x rest-jackson,hibernate-validator
cd validator-demoAnd as usual, you can grab the full tutorial from my Github repository and clone it from there. Roughly half way through it, you’ll need it anyway.
Validating a Simple User Registration
We begin with a classic User Registration example. This demonstrates the most common built-in constraints.
User.java
package org.acme.user;
import java.time.LocalDate;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
public class User {
@NotBlank(message = “Username is required”)
private String username;
@Email(message = “Invalid email address”)
@NotBlank
private String email;
@Past(message = “Birth date must be in the past”)
@NotNull(message = “Birth date is required”)
private LocalDate birthDate;
// getters and setters
}
UserResource.java
package org.acme.user;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/users”)
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class UserResource {
@POST
public Response register(@Valid User user) {
return Response.ok(user).build();
}
}Automated Test
Before we can do automated tests, let’s add one of my favorite libraries to the application:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>Start your application:
./mvnw quarkus:devAnd from now on, you can actually keep the application running and just add more code in your IDE.
Live Test
Try an invalid payload:
curl -X POST http://localhost:8080/users \
-H “Content-Type: application/json” \
-d ‘{”username”:”“,”email”:”john.doe-example-com”,”birthDate”:”1990-01-15”}’You’ll get a 400 Bad Request with JSON validation errors.
{
“title”: “Constraint Violation”,
“status”: 400,
“violations”: [
{
“field”: “register.user.email”,
“message”: “Invalid email address”
},
{
“field”: “register.user.username”,
“message”: “Username is required”
}
]
}Time to add a test:
package org.acme.user;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
@QuarkusTest
class UserResourceTest {
@Test
void shouldRejectInvalidUser() {
given()
.contentType(ContentType.JSON)
.body(”{\”username\”:\”\”,\”email\”:\”not-an-email\”,\”password\”:\”123\”}”)
.when()
.post(”/users”)
.then()
.statusCode(400)
.body(containsString(”Invalid email address”));
}
}
If you go to the quarkus console, you can run the tests by simply hitting “r”:
All 6 tests are passing (0 skipped), 6 tests were run in 1284ms.Validating Business Logic in Services
Validation doesn’t stop at REST endpoints. We can also protect service methods.
Enable method validation in application.properties:
quarkus.hibernate-validator.method-validation.enabled=trueCalculator.java
package org.acme.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.validation.constraints.Min;
@ApplicationScoped
public class Calculator {
public int multiply(@Min(1) int a, @Min(1) int b) {
return a * b;
}
}Automated Test
Let’s also cover this with an automated test:
package org.acme.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.validation.ConstraintViolationException;
@QuarkusTest
class CalculatorTest {
@Inject
Calculator calculator;
@Test
void shouldMultiplyValidInputs() {
int result = calculator.multiply(3, 4);
assertEquals(12, result);
}
@Test
void shouldThrowOnInvalidInput() {
assertThrows(ConstraintViolationException.class,
() -> calculator.multiply(0, 5));
}
}
This ensures bad input can’t sneak deeper into your business logic.
All 8 tests are passing (0 skipped), 8 tests were run in 1301ms.Adding a Strong Password Validator
Built-in annotations are not enough for enterprise systems.
StrongPasswordValidator.java
package org.acme.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null)
return true; // @NotBlank covers null/empty
return value.matches(”^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@#$%^&+=]).+$”);
}
}Let’s add a custom @StrongPassword validator.
StrongPassword.java
package org.acme.validation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
@Documented
@Constraint(validatedBy = StrongPasswordValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface StrongPassword {
String message() default “Password must include upper, lower, number, and special character”;
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}Update User.java:
import org.acme.validation.StrongPassword;
@StrongPassword
private String password;
//getters and settersAutomated Test
Add the following test to the UserResourceTest
@Test
void shouldRejectWeakPassword() {
given()
.contentType(ContentType.JSON)
.body(”{\”username\”:\”markus\”,\”email\”:\”markus@example.com\”,\”password\”:\”abc123\”}”)
.when()
.post(”/users”)
.then()
.statusCode(400)
.body(containsString(”Password must include”));
}All 9 tests are passing (0 skipped), 7 tests were run in 216ms.Note: Quarkus only runs “new” tests. So, the 7 that actually had changed files.
Validating Nested Objects
Now let’s grow the domain with orders. An Order has a Customer and Address. Without @Valid, these nested objects won’t be checked.
Customer.java
package org.acme.order;
import jakarta.validation.constraints.NotBlank;
public class Customer {
@NotBlank
private String name;
@NotBlank
private String email;
}Address.java
package org.acme.order;
import jakarta.validation.constraints.NotBlank;
public class Address {
@NotBlank
private String street;
@NotBlank
private String city;
}Order.java
package org.acme.order;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
public class Order {
@NotNull
@Valid
private Customer customer;
@Valid
private Address address;
}OrderResource.java
package org.acme.order;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/orders”)
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class OrderResource {
@POST
public Response placeOrder(@Valid Order order) {
return Response.ok(order).build();
}
}Live test with curl:
Let’s test this new endpoint:
curl -X POST \
http://localhost:8080/orders \
-H ‘Content-Type: application/json’ \
-d ‘{
“customer”: {
“name”: “”,
“email”: “john.doe@example.com”
}
}’ Should return a failure case:
{
“title”: “Constraint Violation”,
“status”: 400,
“violations”: [
{
“field”: “placeOrder.order.customer”,
“message”: “must not be null”
}
]
}Automated Test
Let’s add a test to the UserResourceTest
@Test
void shouldRejectInvalidNestedCustomer() {
given()
.contentType(ContentType.JSON)
.body(”{\”customer\”:{\”name\”:\”\”,\”email\”:\”\”},\”address\”:{\”street\”:\”\”,\”city\”:\”\”}}”)
.when()
.post(”/orders”)
.then()
.statusCode(400)
.body(containsString(”must not be null”));
}
And now we do see:
All 10 tests are passing (0 skipped), 8 tests were run in 214ms.Validating Collections
Add order items and validate the list.
OrderItem.java
package org.acme.order;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public class OrderItem {
@NotBlank
private String productCode;
@Min(1)
private int quantity;
}Order.java
Add this property:
@NotNull @Valid private java.util.List<OrderItem> items;Automated Test
Let’s cover this as automated test in a new OrderResourceTest
package org.acme.order;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
public class OrderResourceTest {
@Test
public void testInvalidOrder_NullItems() {
String invalidOrderJson = buildOrderJsonWithoutItems(”John Doe”, “john.doe@example.com”);
given()
.contentType(ContentType.JSON)
.body(invalidOrderJson)
.when()
.post(”/orders”)
.then()
.statusCode(400)
.body(containsString(”must not be null”));
}
}I’ve added a couple of more tests to the full OrderResourceTest. Go and grab it from my Github repository. There you can also see the JSON Builder Pattern in action.
All 20 tests are passing (0 skipped), 10 tests were run in 199ms.Different Rules for Create vs Update
Some fields are only required when creating an order. We handle this with validation groups.
Note: Introducing this is going to break everything we did so far. You will have to update the @ConvertGroup(to = ValidationGroups.OnCreate.class) to the Order’s placeOrder method, it only converted the Order’s own validation constraints to the OnCreate group. However, the nested objects (Customer, Address, OrderItem) still had their validation constraints in the default group, so they weren’t being validated during create operations. And there is some more to do. I have the complete source code up on my Github with all necessary changes. So, go check it out and grab it from there.
ValidationGroups.java
package org.acme.validation;
public class ValidationGroups {
public interface OnCreate {
}
public interface OnUpdate {
}
}Order.java
Update the Order.java class and add:
import org.acme.validation.ValidationGroups.OnCreate;
import jakarta.validation.constraints.NotBlank;
@NotBlank(groups = OnCreate.class)
private String orderNumber;OrderResource.java
Update the OrderResource and add an updateOrder method:
import org.acme.validation.ValidationGroups;
import jakarta.validation.groups.ConvertGroup;
@POST
public Response placeOrder(@Valid @ConvertGroup(to = ValidationGroups.OnCreate.class) Order order) {
return Response.ok(order).build();
}
@PUT
@Path(”/{id}”)
public Response updateOrder(@PathParam(”id”) Long id,
@Valid @ConvertGroup(to = ValidationGroups.OnUpdate.class) Order order) {
return Response.ok(order).build();
}Automated Test
@Test
void shouldRequireOrderNumberOnCreate() {
String orderJsonWithoutOrderNumber = buildOrderJsonWithoutOrderNumber(”A”, “a@b.com”, “X”, 1);
given()
.contentType(ContentType.JSON)
.body(orderJsonWithoutOrderNumber)
.when()
.post(”/orders”)
.then()
.statusCode(400)
.body(containsString(”orderNumber”));
}
Cross-Field Validation
Now we enforce rules across multiple fields, like requiring an event’s end date to be after the start date.
Event.java
package org.acme.event;
import org.acme.validation.ChronologicalDates;
import java.time.LocalDate;
@ChronologicalDates
public class Event {
private LocalDate start;
private LocalDate end;
//getter and setter
}ChronologicalDatesValidator.java
package org.acme.validation;
import org.acme.event.Event;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class ChronologicalDatesValidator implements ConstraintValidator<ChronologicalDates, Event> {
@Override
public boolean isValid(Event event, ConstraintValidatorContext ctx) {
if (event.getStart() == null || event.getEnd() == null)
return true;
return event.getEnd().isAfter(event.getStart());
}
}ChronologicalDates.java
package org.acme.validation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import jakarta.validation.Constraint;
@Documented
@Constraint(validatedBy = ChronologicalDatesValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ChronologicalDates {
String message() default “End date must be after start date”;
Class<?>[] groups() default {};
Class<?>[] payload() default {};
}EventResource.java
package org.acme.event;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/events”)
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class EventResource {
@POST
public Response create(@Valid Event event) {
return Response.ok(event).build();
}
}Automated Test
I added a bunch of tests to the:
package org.acme.event;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.containsString;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
public class EventResourceTest {
@Test
public void testCreateEventWithEndDateBeforeStartDate() {
String invalidEventJson = buildEventJson(”2024-01-20”, “2024-01-15”);
given()
.contentType(ContentType.JSON)
.body(invalidEventJson)
.when()
.post(”/events”)
.then()
.statusCode(400)
.body(containsString(”End date must be after start date”));
}
}But this already shows how you can check the validation is working. And now you already have 34 tests:
All 34 tests are passing (0 skipped), 11 tests were run in 187ms.Context-Aware Validation
Sometimes validation requires external state. Imagine a discount that must not exceed the user’s allowed limit. Here we use CDI injection in a validator.
ValidDiscount.java
package org.acme.validation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import jakarta.validation.Constraint;
@Documented
@Constraint(validatedBy = DiscountValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidDiscount {
String message() default “Discount exceeds allowed limit”;
Class<?>[] groups() default {};
Class<?>[] payload() default {};
}DiscountValidator.java
package org.acme.validation;
import org.acme.service.UserService;
import jakarta.inject.Inject;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class DiscountValidator implements ConstraintValidator<ValidDiscount, Integer> {
@Inject
UserService userService;
@Override
public boolean isValid(Integer discount, ConstraintValidatorContext ctx) {
if (discount == null)
return true;
int limit = userService.getCurrentUserDiscountLimit();
return discount <= limit;
}
}UserService.java
package org.acme.service;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class UserService {
public int getCurrentUserDiscountLimit() {
return 20; // in real life, check DB or security context
}
}Product.java
package org.acme.product;
import org.acme.validation.ValidDiscount;
public class Product {
private String name;
@ValidDiscount
private Integer discount;
//getter and setter
}ProductResource.java
package org.acme.product;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/products")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ProductResource {
@POST
public Response create(@Valid Product product) {
return Response.ok(product).build();
}
}Automated Test
package org.acme.product;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
@QuarkusTest
class ProductResourceTest {
@Test
void shouldRejectDiscountAboveLimit() {
given()
.contentType(ContentType.JSON)
.body(”{\”name\”:\”Widget\”,\”discount\”:50}”)
.when()
.post(”/products”)
.then()
.statusCode(400)
.body(org.hamcrest.Matchers.containsString(”Discount exceeds allowed limit”));
}
}
This shows validators pulling context from CDI beans, which is a powerful pattern for real business rules.
And if you re-run the tests now:
All 35 tests are passing (0 skipped), 35 tests were run in 1541ms.I think that I really made up with this for all the tests I missed in my other tutorials ;-)
Closing Thoughts
We started with a simple DTO and ended with custom validators, nested objects, collections, groups, cross-field checks, and CDI-aware context validation. Every step was backed by live curl tests and JUnit tests.
Hibernate Validator in Quarkus isn’t just about checking emails. It’s about encoding your business invariants at the framework level so they can’t be bypassed.
Validation is your silent guardian. Use it well.



