Quarkus Data Hibernate: A New Path for Java Persistence
Build a small catalog API to see generated repositories, checked HQL, managed updates, stateless writes, paging, validation, and tests in one realistic Quarkus service.
I have written about Panache Next before. The project continues to evolve and just recently changed the name to Quarkus Data. The name really wasn’t the most exciting part of the announcement anyway. That was the data access model behind it: repositories generated at build time, checked queries, managed and stateless Hibernate styles, and a path that can cover blocking and reactive code without making you rewrite the entity model.
With the new name Quarkus Data, future relational database work starts with the quarkus-data-hibernate extension. The Quarkus announcement positions it as an umbrella for data access in Quarkus, built around the Jakarta Data direction and the Hibernate processor. I like the new name. “Panache” only meant something to you, if you’d already knew what it was all about. “Data” is what people search for when they are starting a service and need a database.
The API is still experimental in Quarkus 3.37.0, so keep that in mind. Names can still move. But the direction is clear enough to build with, test, and judge by behavior. The team is still looking for feedback about it. If you find something, make sure to report and let the team know.
We will build CatalogBoard, a small product catalog API. It is deliberately plain: products have a SKU, category, stock count, reorder point, and a discontinued flag. That gives us enough room to show generated finder methods, HQL queries checked at build time, managed updates, one explicit stateless update, pagination, validation, and tests.
When we are done, the API can create products, list them by category, search by name, flag low stock, adjust inventory, and hide discontinued products.
Prerequisites
You need a recent Quarkus setup and a container runtime for PostgreSQL Dev Services. The example uses Quarkus 3.37.0 and Java 21.
Java 25
Quarkus CLI
Podman or another Testcontainers-compatible runtime
Basic Jakarta Persistence and REST knowledge
About three ☕️
Quarkus Data Hibernate is experimental in this release. That works for a tutorial and for early project exploration. For a production service, put that status in the decision record so nobody has to rediscover it during an upgrade.
Create The Project
Create the app and follow along or grab the code from my repository:
quarkus create app io.mainthread.catalogboard:catalog-board \
--extension=quarkus-data-hibernate,rest-jackson,jdbc-postgresql,hibernate-validator \
--no-code
cd catalog-boardUse these extensions:
quarkus-data-hibernate- the new Quarkus Data Hibernate entry point for relational data accessrest-jackson- JSON REST endpointsjdbc-postgresql- PostgreSQL connectivity and Dev Serviceshibernate-validator- validation on request bodies and query parameters
The generated project already has the platform-managed dependencies. Quarkus Data Hibernate also needs the Hibernate annotation processor so the repository implementations and query checks happen during compilation. Add this to the maven-compiler-plugin configuration in pom.xml:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
<annotationProcessorPathsUseDepMgmt>true</annotationProcessorPathsUseDepMgmt>
<annotationProcessorPaths>
<path>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>The annotationProcessorPathsUseDepMgmt flag lets the Quarkus BOM manage the processor version. Without the processor, the repository interfaces stay as interfaces. The generated implementation never appears.
Configure PostgreSQL
Add src/main/resources/application.properties:
quarkus.datasource.db-kind=postgresql
%dev.quarkus.datasource.devservices.image-name=docker.io/library/postgres:17
%test.quarkus.datasource.devservices.image-name=docker.io/library/postgres:17
%dev.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%test.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%prod.quarkus.hibernate-orm.schema-management.strategy=validate
%prod.quarkus.datasource.username=${DB_USERNAME}
%prod.quarkus.datasource.password=${DB_PASSWORD}
%prod.quarkus.datasource.jdbc.url=${DB_JDBC_URL}
quarkus.hibernate-orm.log.sql=falseDev and test mode use PostgreSQL Dev Services. The schema is recreated on startup so the tutorial stays repeatable. Production uses validate because the application should not mutate real tables at startup. Use Flyway or Liquibase there.
The db-kind property is the one setting we keep outside a profile. It tells Quarkus which datasource to prepare even before a real URL exists.
Add The Entity And Repositories
Create src/main/java/io/mainthread/catalogboard/Product.java:
package io.mainthread.catalogboard;
import java.util.List;
import java.util.Optional;
import org.hibernate.annotations.processing.Find;
import org.hibernate.annotations.processing.HQL;
import io.quarkus.hibernate.panache.PanacheEntity;
import io.quarkus.hibernate.panache.PanacheRepository;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(name = "products", uniqueConstraints = @UniqueConstraint(columnNames = "sku"))
public class Product extends PanacheEntity {
@Column(nullable = false, length = 32)
public String sku;
@Column(nullable = false, length = 120)
public String name;
@Column(nullable = false, length = 80)
public String category;
@Column(nullable = false)
public int stock;
@Column(nullable = false)
public int reorderPoint;
@Column(nullable = false)
public boolean discontinued;
public boolean needsRestock() {
return !discontinued && stock <= reorderPoint;
}
public interface Repo extends PanacheRepository<Product> {
@Find
Optional<Product> findBySku(String sku);
@HQL("where category = :category and discontinued = false order by name")
List<Product> findAvailableByCategory(String category);
@HQL("where name like :pattern and discontinued = false order by name")
List<Product> searchByName(String pattern);
@HQL("where stock <= reorderPoint and discontinued = false order by stock, sku")
List<Product> findLowStock();
@HQL("delete from Product where discontinued = true")
long deleteDiscontinued();
}
public interface InventoryRepo extends PanacheRepository.Stateless<Product, Long> {
@Find
Optional<Product> findBySku(String sku);
@HQL("update Product set stock = stock + :delta where sku = :sku and discontinued = false")
long changeStock(String sku, int delta);
}
}This file shows the core Quarkus Data model. The entity is ordinary Jakarta Persistence plus the Quarkus Data PanacheEntity base type. The nested repository interfaces are where the generated code appears.
@Find derives a query from the method name and parameters. findBySku(String sku) becomes a query against the sku field. Returning Optional<Product> is intentional. If the method returns Product, absence becomes a NoResultException. For a REST lookup, Optional gives the resource a cleaner boundary.
@HQL gives us explicit queries while still letting the Hibernate processor check the entity name, field names, syntax, and parameter names during compilation. Change reorderPoint to reorderLevel in the entity and forget to update the query, and the build catches it.
The second repository is stateless. Managed Hibernate tracks entity changes and flushes dirty state at transaction boundaries. A stateless repository does less tracking, so write operations stay explicit. changeStock is an update query because adjusting inventory is a command. That path should not depend on “load, mutate, hope somebody remembered the flush.”
Quarkus Data also supports Jakarta Data annotations such as jakarta.data.repository.Find, Query, and Delete. The current Quarkus Data Hibernate guide shows both styles. I use the Hibernate annotations here because we are writing HQL and staying close to Hibernate ORM.
Add Request And Response Types
Keep JSON payloads away from the entity. The entity is the persistence model. The REST API can stay smaller.
Create ProductCreateRequest.java:
package io.mainthread.catalogboard;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ProductCreateRequest(
@NotBlank @Size(max = 32) String sku,
@NotBlank @Size(max = 120) String name,
@NotBlank @Size(max = 80) String category,
@Min(0) int stock,
@Min(0) int reorderPoint) {
}Create ProductUpdateRequest.java:
package io.mainthread.catalogboard;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ProductUpdateRequest(
@NotBlank @Size(max = 120) String name,
@NotBlank @Size(max = 80) String category,
@Min(0) int reorderPoint) {
}Create StockAdjustment.java:
package io.mainthread.catalogboard;
import jakarta.validation.constraints.Min;
public record StockAdjustment(@Min(-1000) int delta) {
}Create ProductResponse.java:
package io.mainthread.catalogboard;
public record ProductResponse(
Long id,
String sku,
String name,
String category,
int stock,
int reorderPoint,
boolean discontinued,
boolean needsRestock) {
static ProductResponse from(Product product) {
return new ProductResponse(
product.id,
product.sku,
product.name,
product.category,
product.stock,
product.reorderPoint,
product.discontinued,
product.needsRestock());
}
}The create request includes sku; the update request does not. That prevents clients from changing the natural lookup key through a metadata update. You can pick a different boundary in your system, but it should be a deliberate choice.
Add The REST Resource
Create src/main/java/io/mainthread/catalogboard/ProductResource.java:
package io.mainthread.catalogboard;
import java.net.URI;
import java.util.List;
import io.quarkus.hibernate.panache.blocking.PanacheBlockingQuery;
import jakarta.data.Order;
import jakarta.data.Sort;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {
@Inject
Product.Repo repo;
@Inject
Product.InventoryRepo inventoryRepo;
@GET
public List<ProductResponse> list(
@QueryParam("category") String category,
@QueryParam("page") @DefaultValue("0") @Min(0) int page,
@QueryParam("size") @DefaultValue("20") @Min(1) @Max(100) int size) {
PanacheBlockingQuery<Product> query = category == null || category.isBlank()
? repo.find("discontinued = false", Order.by(Sort.asc("name")))
: repo.find("category = ?1 and discontinued = false", Order.by(Sort.asc("name")), category);
return query.page(page, size)
.list()
.stream()
.map(ProductResponse::from)
.toList();
}
@GET
@Path("/{sku}")
public ProductResponse get(@PathParam("sku") String sku) {
return ProductResponse.from(requireProduct(sku));
}
@GET
@Path("/search")
public List<ProductResponse> search(@QueryParam("q") @DefaultValue("") String query) {
if (query.isBlank()) {
return List.of();
}
return repo.searchByName("%" + query + "%")
.stream()
.map(ProductResponse::from)
.toList();
}
@GET
@Path("/low-stock")
public List<ProductResponse> lowStock() {
return repo.findLowStock()
.stream()
.map(ProductResponse::from)
.toList();
}
@POST
@Transactional
public Response create(@Valid ProductCreateRequest request) {
if (repo.findBySku(request.sku()).isPresent()) {
throw new BadRequestException("SKU already exists: " + request.sku());
}
Product product = new Product();
product.sku = request.sku();
product.name = request.name();
product.category = request.category();
product.stock = request.stock();
product.reorderPoint = request.reorderPoint();
repo.persist(product);
return Response.created(URI.create("/products/" + product.sku))
.entity(ProductResponse.from(product))
.build();
}
@PUT
@Path("/{sku}")
@Transactional
public ProductResponse update(@PathParam("sku") String sku, @Valid ProductUpdateRequest request) {
Product product = requireProduct(sku);
product.name = request.name();
product.category = request.category();
product.reorderPoint = request.reorderPoint();
return ProductResponse.from(product);
}
@PATCH
@Path("/{sku}/stock")
@Transactional
public ProductResponse changeStock(@PathParam("sku") String sku, @Valid StockAdjustment adjustment) {
long updated = inventoryRepo.changeStock(sku, adjustment.delta());
if (updated == 0) {
throw new NotFoundException("Active product not found: " + sku);
}
return ProductResponse.from(inventoryRepo.findBySku(sku)
.orElseThrow(() -> new NotFoundException("Product not found after stock change: " + sku)));
}
@DELETE
@Path("/{sku}")
@Transactional
public Response discontinue(@PathParam("sku") String sku) {
Product product = requireProduct(sku);
product.discontinued = true;
return Response.noContent().build();
}
@DELETE
@Path("/maintenance/discontinued")
@Transactional
public Response deleteDiscontinued() {
long deleted = repo.deleteDiscontinued();
return Response.ok().entity(new DeletedProducts(deleted)).build();
}
private Product requireProduct(String sku) {
Product product = repo.findBySku(sku)
.orElseThrow(() -> new NotFoundException("Product not found: " + sku));
if (product.discontinued) {
throw new NotFoundException("Product not found: " + sku);
}
return product;
}
public record DeletedProducts(long deleted) {
}
}This class uses two different styles on purpose.
Create, update, and discontinue use the managed repository. Inside a transaction, Hibernate tracks the entity. When update changes name, category, and reorderPoint, there is no explicit repo.update(product) because the entity is already managed.
changeStock uses the stateless repository. That method does not load the product first. It runs one update query and returns the updated row afterward. If the update count is zero, the product is missing or discontinued. That gives the endpoint a direct 404 path and keeps the command explicit.
The list endpoint uses jakarta.data.Order and Sort with Panache query paging. Quarkus Data also has REST integration for Jakarta Data types such as PageRequest, Order, Sort, and Limit when quarkus-data-hibernate and Quarkus REST are both present. The official guide shows a PageRequest and Order<T> endpoint shape for that case. Here I keep page and size as plain query parameters because I want the paging mechanics to stay visible.
Try The API
Start dev mode:
./mvnw quarkus:devCreate a product:
curl -s -X POST http://localhost:8080/products \
-H 'Content-Type: application/json' \
-d '{
"sku": "SKU-100",
"name": "Field Notebook",
"category": "stationery",
"stock": 8,
"reorderPoint": 3
}' | jqExpected shape:
{
"category": "stationery",
"discontinued": false,
"id": 1,
"name": "Field Notebook",
"needsRestock": false,
"reorderPoint": 3,
"sku": "SKU-100",
"stock": 8
}Adjust stock:
curl -s -X PATCH http://localhost:8080/products/SKU-100/stock \
-H 'Content-Type: application/json' \
-d '{"delta": -6}' | jqThe product now needs restocking:
{
"sku": "SKU-100",
"stock": 2,
"reorderPoint": 3,
"needsRestock": true
}The real response includes all fields. The shortened output above shows the part you want to check.
List low-stock products:
curl -s http://localhost:8080/products/low-stock | jqSearch by name:
curl -s 'http://localhost:8080/products/search?q=Notebook' | jqDiscontinue the product:
curl -i -X DELETE http://localhost:8080/products/SKU-100After that, GET /products/SKU-100 returns 404 because the API hides discontinued products from the active catalog.
Prove It With Tests
Create src/test/java/io/mainthread/catalogboard/ProductResourceTest.java:
package io.mainthread.catalogboard;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
class ProductResourceTest {
@Test
void createsAndReadsProduct() {
String sku = "SKU-" + System.nanoTime();
given()
.contentType(ContentType.JSON)
.body("""
{
"sku": "%s",
"name": "Field Notebook",
"category": "stationery",
"stock": 8,
"reorderPoint": 3
}
""".formatted(sku))
.when()
.post("/products")
.then()
.statusCode(201)
.body("sku", equalTo(sku))
.body("needsRestock", equalTo(false));
given()
.when()
.get("/products/{sku}", sku)
.then()
.statusCode(200)
.body("name", equalTo("Field Notebook"))
.body("category", equalTo("stationery"));
}
@Test
void listsProductsByCategoryWithPagination() {
String category = "category-" + System.nanoTime();
createProduct("SKU-A-" + System.nanoTime(), "Alpha Binder", category, 4, 2);
createProduct("SKU-B-" + System.nanoTime(), "Beta Binder", category, 4, 2);
given()
.queryParam("category", category)
.queryParam("page", 0)
.queryParam("size", 1)
.when()
.get("/products")
.then()
.statusCode(200)
.body("", hasSize(1))
.body("[0].name", equalTo("Alpha Binder"));
}
@Test
void changesStockThroughStatelessRepositoryMethod() {
String sku = "SKU-STOCK-" + System.nanoTime();
createProduct(sku, "Shelf Label", "warehouse", 2, 5);
given()
.contentType(ContentType.JSON)
.body("""
{
"delta": 4
}
""")
.when()
.patch("/products/{sku}/stock", sku)
.then()
.statusCode(200)
.body("stock", equalTo(6))
.body("needsRestock", equalTo(false));
}
@Test
void findsLowStockProducts() {
String sku = "SKU-LOW-" + System.nanoTime();
createProduct(sku, "Packing Tape", "warehouse", 1, 5);
given()
.when()
.get("/products/low-stock")
.then()
.statusCode(200)
.body("sku", hasItem(sku))
.body("findAll { it.sku == '%s' }.size()".formatted(sku), greaterThanOrEqualTo(1));
}
@Test
void hidesDiscontinuedProductsFromCatalog() {
String sku = "SKU-DISC-" + System.nanoTime();
createProduct(sku, "Legacy Marker", "stationery", 4, 2);
given()
.when()
.delete("/products/{sku}", sku)
.then()
.statusCode(204);
given()
.when()
.get("/products/{sku}", sku)
.then()
.statusCode(404);
given()
.when()
.get("/products/search?q=Legacy")
.then()
.statusCode(200)
.body("sku", not(hasItem(sku)));
}
@Test
void rejectsInvalidCreateRequest() {
given()
.contentType(ContentType.JSON)
.body("""
{
"sku": "",
"name": "",
"category": "stationery",
"stock": -1,
"reorderPoint": 0
}
""")
.when()
.post("/products")
.then()
.statusCode(400);
}
private void createProduct(String sku, String name, String category, int stock, int reorderPoint) {
given()
.contentType(ContentType.JSON)
.body("""
{
"sku": "%s",
"name": "%s",
"category": "%s",
"stock": %d,
"reorderPoint": %d
}
""".formatted(sku, name, category, stock, reorderPoint))
.when()
.post("/products")
.then()
.statusCode(201);
}
}Run the tests:
./mvnw testExpected result:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0The tests check behavior, not just startup. Product creation goes through validation and persistence. Category listing proves sorting and paging. The stock test proves the stateless update method. The discontinued test proves the API hides soft-deleted products from reads and search.
Before you move on, make one deliberate mistake: change reorderPoint in Product to reorderLevel and compile. The findLowStock HQL query still points at reorderPoint, so the processor should fail the build. That is exactly what you want. A broken query should fail before the service boots.
What Changed From Classic Panache
Classic Panache is still available. The Quarkus team says Panache 1 is not going away. Existing services do not need a panic migration, which is good because teams rarely do their best architecture work in a panic.
For new code, Quarkus Data Hibernate is the direction of travel.
The important changes are:
A clearer extension name -
quarkus-data-hibernateis the relational data entry pointGenerated repositories - interfaces are implemented at build time
Checked queries -
@Findand@HQLmethods are validated against the entity modelManaged and stateless choices - dirty checking when you want it, explicit writes when you do not
Blocking and reactive variants - reactive support uses extra dependencies, but the model is designed to cover both
Jakarta Data alignment - Quarkus Data can use Jakarta Data annotations and types where they fit
REST integration - Jakarta Data paging and sorting types can be populated from HTTP query parameters
This sample uses blocking Hibernate ORM because it is the path most Java readers should learn first. Reactive is there too, but that’s another article in the furture.
Production Notes
The extension is experimental in Quarkus 3.37.0. You should expect some API movement.
Do not carry drop-and-create into production. It deletes data on startup. In real environments, let Flyway or Liquibase own schema changes and keep Hibernate ORM on validate.
Keep transaction boundaries at the service or resource boundary. In this small app the resource is the boundary. In a larger service, I would move the write methods into an application service and keep the REST class thinner.
Be careful with repository security annotations. The current guide notes that annotations on a repository type do not secure inherited methods. Put security on the resource or service method that represents the real operation, then add repository method annotations only where they are directly declared and tested.
Finally, @HQL is not a license to build strings from user input. Use parameters, as changeStock does. The compiler can check your query shape. It cannot fix a bad authorization rule.
Conclusion
Quarkus Data Hibernate gives Quarkus a clearer database entry point and a better repository story: generated methods, checked queries, managed writes where they help, and explicit stateless operations where they are clearer. The name changed from Panache Next, but the important part is the build-time contract around the data layer.


