Build Your First Type-Safe Database Queries with Quarkus Qubit
Create a small REST API and learn how Qubit turns Java lambdas into JPA Criteria queries during the Quarkus build.
Rename a Java field and a string query can still compile. The compiler sees "openedAt" as plain text. Hibernate finds the problem later when it parses the query. JPA Criteria keeps the field reference in Java, but the code becomes hard to read once we add predicates, sorting, projection, and pagination.
Quarkus Qubit lets us write that query as a Java lambda over an entity. During the Quarkus build, the extension analyzes the lambda and generates a JPA Criteria executor. Captured Java values become query parameters. The compiler can still see entity field access, and we can read the query from left to right.
Qubit still uses Hibernate ORM and JPA Criteria. It adds a build-time step that translates a supported set of Java expressions into Criteria queries.
We will build ReleaseRadar, a small API that answers three questions before a deployment:
Which high-severity issues have been open longer than a chosen cutoff?
Which services have accumulated the most open issues?
Which open issues affect more users than the average open issue?
The application uses Qubit 1.0.0, Quarkus 3.32.2, Java 25, Hibernate ORM with Panache, PostgreSQL, and Quarkus REST. We will also test two limits in this preview release before deciding whether it belongs in a production build.
Before you start
You need:
JDK 25 on
PATHthe Quarkus CLI
Podman with a running machine or socket
curlabout ☕️☕️☕️
Qubit 1.0.0 is a preview extension and requires Java 25.
Create the application
Create a Maven project with the core Quarkus extensions first or start from my Github repository:
quarkus create app com.themainthread:release-radar-qubit \
-P io.quarkus.platform:quarkus-bom:3.32.2 \
--java=25 \
--no-code \
--extensions=rest-jackson,hibernate-orm-panache,jdbc-postgresql
cd release-radar-qubitUse these Quarkus extensions:
quarkus-rest-jacksonadds REST endpoints and JSON support.quarkus-hibernate-orm-panacheadds Hibernate ORM and the Panache base used by Qubit.quarkus-jdbc-postgresqladds the PostgreSQL JDBC driver and database Dev Services.
Qubit 1.0.0 publishes a direct Maven dependency. Add its version beside the other properties in pom.xml:
<quarkus-qubit.version>1.0.0</quarkus-qubit.version>Then add the dependency inside the existing <dependencies> element:
<dependency>
<groupId>io.quarkiverse.qubit</groupId>
<artifactId>quarkus-qubit</artifactId>
<version>${quarkus-qubit.version}</version>
</dependency>Qubit sits outside the Quarkus platform BOM, so its version must be set in the POM.
Model a release issue
ReleaseRadar needs severity and status enums. Create src/main/java/com/themainthread/releaseradar/domain/IssueSeverity.java:
package com.themainthread.releaseradar.domain;
public enum IssueSeverity {
LOW,
MEDIUM,
HIGH,
CRITICAL
}Create src/main/java/com/themainthread/releaseradar/domain/IssueStatus.java:
package com.themainthread.releaseradar.domain;
public enum IssueStatus {
OPEN,
RESOLVED
}Now create src/main/java/com/themainthread/releaseradar/domain/Issue.java:
package com.themainthread.releaseradar.domain;
import java.time.LocalDateTime;
import io.quarkiverse.qubit.QubitEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
@Entity
@Table(name = "release_issue")
public class Issue extends QubitEntity {
@Column(name = "issue_key", nullable = false, unique = true)
public String key;
@Column(nullable = false)
public String service;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public IssueSeverity severity;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
public IssueStatus status;
@Column(name = "opened_at", nullable = false)
public LocalDateTime openedAt;
@Column(name = "affected_users", nullable = false)
public int affectedUsers;
}QubitEntity is Qubit’s entity base for the active record pattern. It supplies the inherited identifier and enables Qubit’s generated query methods. We keep our queries in a repository so all release policy stays in one class.
The public fields follow the Panache entity style. They also let the query lambdas use direct field access such as issue.openedAt.
Add deterministic development data
We need stable data because the cutoff and average calculations must produce the same answer on every machine. Create src/main/resources/import.sql:
INSERT INTO release_issue (id, issue_key, service, severity, status, opened_at, affected_users)
VALUES (101, 'REL-101', 'payments', 'CRITICAL', 'OPEN', '2026-07-12 12:00:00', 1200);
INSERT INTO release_issue (id, issue_key, service, severity, status, opened_at, affected_users)
VALUES (102, 'REL-102', 'catalog', 'HIGH', 'OPEN', '2026-07-13 12:00:00', 450);
INSERT INTO release_issue (id, issue_key, service, severity, status, opened_at, affected_users)
VALUES (103, 'REL-103', 'search', 'MEDIUM', 'OPEN', '2026-07-11 12:00:00', 60);
INSERT INTO release_issue (id, issue_key, service, severity, status, opened_at, affected_users)
VALUES (104, 'REL-104', 'payments', 'HIGH', 'OPEN', '2026-07-15 00:00:00', 2000);
INSERT INTO release_issue (id, issue_key, service, severity, status, opened_at, affected_users)
VALUES (105, 'REL-105', 'identity', 'CRITICAL', 'RESOLVED', '2026-07-10 12:00:00', 800);
INSERT INTO release_issue (id, issue_key, service, severity, status, opened_at, affected_users)
VALUES (106, 'REL-106', 'catalog', 'CRITICAL', 'OPEN', '2026-07-08 12:00:00', 300);
INSERT INTO release_issue (id, issue_key, service, severity, status, opened_at, affected_users)
VALUES (107, 'REL-107', 'search', 'HIGH', 'OPEN', '2026-07-14 06:00:00', 150);
INSERT INTO release_issue (id, issue_key, service, severity, status, opened_at, affected_users)
VALUES (108, 'REL-108', 'payments', 'LOW', 'OPEN', '2026-07-05 12:00:00', 20);The data covers both accepted and rejected cases: a recent open issue, lower severities, and a resolved critical issue. The test can now check the query rules and the exact result.
Define the API projections
Returning managed entities from a REST API couples the response to the persistence model. Qubit supports constructor projections, so we use three records for the responses.
Create src/main/java/com/themainthread/releaseradar/api/BlockerView.java:
package com.themainthread.releaseradar.api;
import java.time.LocalDateTime;
import com.themainthread.releaseradar.domain.IssueSeverity;
public record BlockerView(
String key,
String service,
IssueSeverity severity,
LocalDateTime openedAt,
int affectedUsers) {
}Create src/main/java/com/themainthread/releaseradar/api/ServiceHotspot.java:
package com.themainthread.releaseradar.api;
public record ServiceHotspot(
String service,
long openIssues,
Double averageAffectedUsers) {
}Create src/main/java/com/themainthread/releaseradar/api/ImpactOutlier.java:
package com.themainthread.releaseradar.api;
public record ImpactOutlier(
String key,
String service,
int affectedUsers) {
}averageAffectedUsers uses Double because that is the aggregate type returned by Qubit’s avg expression.
Write the Qubit repository
Create src/main/java/com/themainthread/releaseradar/persistence/IssueRepository.java:
package com.themainthread.releaseradar.persistence;
import static io.quarkiverse.qubit.Subqueries.subquery;
import java.time.LocalDateTime;
import java.util.List;
import com.themainthread.releaseradar.api.BlockerView;
import com.themainthread.releaseradar.api.ImpactOutlier;
import com.themainthread.releaseradar.api.ServiceHotspot;
import com.themainthread.releaseradar.domain.Issue;
import com.themainthread.releaseradar.domain.IssueSeverity;
import com.themainthread.releaseradar.domain.IssueStatus;
import io.quarkiverse.qubit.Group;
import io.quarkiverse.qubit.QubitRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class IssueRepository implements QubitRepository<Issue, Long> {
public List<BlockerView> findBlockers(
LocalDateTime cutoff,
List<IssueSeverity> severities,
int limit) {
return where(issue -> issue.status == IssueStatus.OPEN)
.where(issue -> severities.contains(issue.severity))
.where(issue -> issue.openedAt.isBefore(cutoff))
.sortedBy(issue -> issue.openedAt)
.limit(limit)
.select(issue -> new BlockerView(
issue.key,
issue.service,
issue.severity,
issue.openedAt,
issue.affectedUsers))
.toList();
}
public List<ServiceHotspot> findHotspots(long minimumOpen) {
return where(issue -> issue.status == IssueStatus.OPEN)
.groupBy(issue -> issue.service)
.having((Group<Issue, String> group) -> group.count() >= minimumOpen)
.sortedDescendingBy((Group<Issue, String> group) -> group.count())
.select((Group<Issue, String> group) -> new ServiceHotspot(
group.key(),
group.count(),
group.avg(issue -> issue.affectedUsers)))
.toList();
}
public List<ImpactOutlier> findImpactOutliers() {
return where(issue -> issue.status == IssueStatus.OPEN
&& issue.affectedUsers > subquery(Issue.class)
.where(candidate -> candidate.status == IssueStatus.OPEN)
.avg(candidate -> candidate.affectedUsers))
.sortedDescendingBy(issue -> Integer.valueOf(issue.affectedUsers))
.select(issue -> new ImpactOutlier(
issue.key,
issue.service,
issue.affectedUsers))
.toList();
}
}The repository uses three query forms.
findBlockers captures severities and cutoff from the method call. Qubit turns severities.contains(issue.severity) into an IN predicate. It turns isBefore(cutoff) into a time comparison. The database sorts the bounded result, and Qubit projects each row into a BlockerView record.
findHotspots starts with Issue and switches to Group<Issue, String> after groupBy. The service name becomes the group key. having filters the grouped rows, and the projection combines the key with count and avg. Qubit orders the result by count. Services with the same count can appear in either order because the query has no second sort field.
findImpactOutliers puts an aggregate scalar subquery inside the outer predicate. A scalar subquery returns one value, which is the average in this case. Both queries only include open issues. Integer.valueOf gives the generic sort expression the boxed Comparable value it expects.
These query shapes stay fixed. Request values can change, but Qubit must understand the lambda structure during the build. For searches that assemble joins and predicates at runtime, use regular Criteria, HQL, or a dedicated search layer.
Expose bounded REST endpoints
Create src/main/java/com/themainthread/releaseradar/api/IssueResource.java:
package com.themainthread.releaseradar.api;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeParseException;
import java.util.List;
import com.themainthread.releaseradar.domain.IssueSeverity;
import com.themainthread.releaseradar.persistence.IssueRepository;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@Path("/issues")
@Produces(MediaType.APPLICATION_JSON)
public class IssueResource {
private static final List<IssueSeverity> DEFAULT_SEVERITIES = List.of(
IssueSeverity.CRITICAL,
IssueSeverity.HIGH);
private final IssueRepository issueRepository;
public IssueResource(IssueRepository issueRepository) {
this.issueRepository = issueRepository;
}
@GET
@Path("/blockers")
public List<BlockerView> blockers(
@QueryParam("asOf") String asOf,
@QueryParam("olderThanHours") @DefaultValue("24") int olderThanHours,
@QueryParam("severity") List<IssueSeverity> severities,
@QueryParam("limit") @DefaultValue("20") int limit) {
if (olderThanHours < 1 || olderThanHours > 8_760) {
throw new BadRequestException("olderThanHours must be between 1 and 8760");
}
if (limit < 1 || limit > 100) {
throw new BadRequestException("limit must be between 1 and 100");
}
LocalDateTime cutoff = parseAsOf(asOf).minusHours(olderThanHours);
List<IssueSeverity> selectedSeverities = severities == null || severities.isEmpty()
? DEFAULT_SEVERITIES
: List.copyOf(severities);
return issueRepository.findBlockers(cutoff, selectedSeverities, limit);
}
@GET
@Path("/hotspots")
public List<ServiceHotspot> hotspots(
@QueryParam("minimumOpen") @DefaultValue("2") long minimumOpen) {
if (minimumOpen < 1 || minimumOpen > 1_000) {
throw new BadRequestException("minimumOpen must be between 1 and 1000");
}
return issueRepository.findHotspots(minimumOpen);
}
@GET
@Path("/outliers")
public List<ImpactOutlier> outliers() {
return issueRepository.findImpactOutliers();
}
private static LocalDateTime parseAsOf(String value) {
if (value == null || value.isBlank()) {
return LocalDateTime.now(ZoneOffset.UTC);
}
try {
return LocalDateTime.parse(value);
} catch (DateTimeParseException exception) {
throw new BadRequestException("asOf must use ISO-8601 local date-time format", exception);
}
}
}The resource uses constructor injection and validates input before calling the repository. limit stops at 100, and the age window also has a maximum. Tests pass an explicit asOf, so their result does not depend on the current time. Normal requests can omit it and use the current UTC time.
Qubit binds captured values as parameters. We still need a result limit and a stable sort because parameter binding does not control how many rows the database returns.
Configure Qubit and PostgreSQL
Replace src/main/resources/application.properties with:
quarkus.datasource.db-kind=postgresql
quarkus.qubit.scanning.include-packages=com.themainthread.releaseradar.
quarkus.qubit.fail-on-analysis-error=true
quarkus.qubit.logging.log-generated-classes=true
%dev.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%dev.quarkus.hibernate-orm.sql-load-script=import.sql
%dev.quarkus.hibernate-orm.log.sql=true
%dev.quarkus.log.category."io.quarkiverse.qubit".level=DEBUG
%test.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%test.quarkus.hibernate-orm.sql-load-script=import.sql
%prod.quarkus.hibernate-orm.schema-management.strategy=validateThe scan prefix limits Qubit’s bytecode analysis to the application package. The two logging settings show the generated executors and the SQL in dev mode.
Development and test profiles recreate the schema and load the sample data. Production uses validate, which checks the schema and leaves it unchanged. Add Flyway or Liquibase migrations and an external datasource before deployment.
We enable fail-on-analysis-error because a missing executor should stop the build. Qubit 1.0.0 does not apply that rule to every generation error. We will reproduce this behavior below.
Run the queries
With Podman running, start Quarkus dev mode:
./mvnw quarkus:devQuarkus Dev Services starts PostgreSQL and loads import.sql. The development profile gets its datasource URL and credentials from Dev Services.
Ask for high and critical issues that were more than 24 hours old at noon on July 15:
curl -sG http://localhost:8080/issues/blockers \
--data-urlencode 'asOf=2026-07-15T12:00:00' \
--data-urlencode 'olderThanHours=24' \
--data-urlencode 'severity=CRITICAL' \
--data-urlencode 'severity=HIGH' \
--data-urlencode 'limit=20'The response is ordered from oldest to newest:
[
{
"key": "REL-106",
"service": "catalog",
"severity": "CRITICAL",
"openedAt": "2026-07-08T12:00:00",
"affectedUsers": 300
},
{
"key": "REL-101",
"service": "payments",
"severity": "CRITICAL",
"openedAt": "2026-07-12T12:00:00",
"affectedUsers": 1200
},
{
"key": "REL-102",
"service": "catalog",
"severity": "HIGH",
"openedAt": "2026-07-13T12:00:00",
"affectedUsers": 450
},
{
"key": "REL-107",
"service": "search",
"severity": "HIGH",
"openedAt": "2026-07-14T06:00:00",
"affectedUsers": 150
}
]At the supplied time, REL-104 has only been open for 12 hours. REL-105 is already resolved. The filter removes both rows.
Now inspect service hotspots:
curl -s 'http://localhost:8080/issues/hotspots?minimumOpen=2'[
{
"service": "payments",
"openIssues": 3,
"averageAffectedUsers": 1073.3333333333333
},
{
"service": "catalog",
"openIssues": 2,
"averageAffectedUsers": 375.0
},
{
"service": "search",
"openIssues": 2,
"averageAffectedUsers": 105.0
}
]Hibernate executes one grouped query. Here is the same SQL with readable formatting:
[Hibernate]
select
i1_0.service,
count(i1_0.id),
avg(i1_0.affected_users)
from
release_issue i1_0
where
i1_0.status='OPEN'
group by
1
having
count(i1_0.id)>=2
order by
count(i1_0.id) descPostgreSQL performs the filter, grouping, HAVING, average, and ordering. The application does not load all issues and aggregate them in a Java stream.
Finally, find issues above the open-issue impact average:
curl -s http://localhost:8080/issues/outliers[
{
"key": "REL-104",
"service": "payments",
"affectedUsers": 2000
},
{
"key": "REL-101",
"service": "payments",
"affectedUsers": 1200
}
]The scalar subquery calculates the average in PostgreSQL. The outer query returns rows above that value.
Inspect the generated executors
Open http://localhost:8080/q/dev-ui/ while dev mode is running. Find the Qubit card and open Lambda Queries.
The page shows three call sites and three generated executor classes. The blocker query has two captured variables: the severity collection and the cutoff. It also shows the reconstructed predicate and projection. The hotspot row has the type Group List, and the outlier row shows its scalar subquery.
Click a query ID to compare the lambda with Qubit’s generated JPQL view. This panel explains the build-time translation. It is not an exact database trace. In 1.0.0, some Java time expressions and subquery aliases are incomplete in this view. Use Hibernate’s SQL log to see the query sent to PostgreSQL.
The Dev UI tells us whether Qubit found a call site and how many values it captured. Query tests check the behavior. A database execution plan is still needed when we check performance.
See an unsupported lambda fail analysis
Qubit understands a defined set of expressions. It cannot analyze any application method that receives the entity lambda parameter.
Try one unsupported expression by making this temporary change in IssueRepository:
@@
- .where(issue -> issue.openedAt.isBefore(cutoff))
+ .where(issue -> isOlderThan(issue, cutoff))
@@
+ private static boolean isOlderThan(Issue issue, LocalDateTime cutoff) {
+ return issue.openedAt.isBefore(cutoff);
+ }Run ./mvnw package -DskipTests. Qubit 1.0.0 reports the call site and the unsupported expression:
[ERROR] Failed to generate executor for call site:
com.themainthread.releaseradar.persistence.IssueRepository:findBlockers:37:lambda$findBlockers$16e17449$1
UnsupportedExpressionException: Unsupported expression type in JPA query generation: Parameter
Expression details: lambda parameter: entity
Context: predicate generation
This lambda pattern cannot be converted to a JPA Criteria query.
Consider simplifying the expression or using a supported pattern.Restore the supported issue.openedAt.isBefore(cutoff) expression before continuing.
This test exposes a limit in the preview release. During my 1.0.0 verification, Qubit logged the generation failure and created only two of the three executors. Maven still ended with BUILD SUCCESS, even with quarkus.qubit.fail-on-analysis-error=true. The setting may stop failures in other analysis paths, but it did not stop this build.
Keep the setting enabled, but make the tests execute every Qubit query. Also check the executor count when you add a call site. A green Maven build alone did not catch the missing executor in this version. Because Qubit is still a preview extension, a later release may change this behavior. The pinned version keeps that change under your control.
Test query behavior against PostgreSQL
Create src/test/java/com/themainthread/releaseradar/api/IssueResourceTest.java:
package com.themainthread.releaseradar.api;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.common.mapper.TypeRef;
import org.junit.jupiter.api.Test;
@QuarkusTest
class IssueResourceTest {
@Test
void returnsOldHighSeverityBlockersInAgeOrder() {
List<BlockerView> blockers = given()
.queryParam("asOf", "2026-07-15T12:00:00")
.queryParam("olderThanHours", 24)
.queryParam("severity", "CRITICAL", "HIGH")
.queryParam("limit", 20)
.when()
.get("/issues/blockers")
.then()
.statusCode(200)
.extract()
.as(new TypeRef<>() {
});
assertEquals(
List.of("REL-106", "REL-101", "REL-102", "REL-107"),
blockers.stream().map(BlockerView::key).toList());
}
@Test
void groupsOpenIssuesByService() {
List<ServiceHotspot> hotspots = given()
.queryParam("minimumOpen", 2)
.when()
.get("/issues/hotspots")
.then()
.statusCode(200)
.extract()
.as(new TypeRef<>() {
});
assertEquals("payments", hotspots.getFirst().service());
assertEquals(3L, hotspots.getFirst().openIssues());
assertEquals(List.of("catalog", "search"),
hotspots.subList(1, hotspots.size()).stream()
.map(ServiceHotspot::service)
.sorted()
.toList());
}
@Test
void findsOpenIssuesAboveTheOpenIssueImpactAverage() {
List<ImpactOutlier> outliers = given()
.when()
.get("/issues/outliers")
.then()
.statusCode(200)
.extract()
.as(new TypeRef<>() {
});
assertEquals(List.of("REL-104", "REL-101"),
outliers.stream().map(ImpactOutlier::key).toList());
}
@Test
void rejectsUnboundedPageSizes() {
given()
.queryParam("limit", 101)
.when()
.get("/issues/blockers")
.then()
.statusCode(400);
}
}Run the test suite:
./mvnw testQuarkus starts PostgreSQL Dev Services for the test profile. Qubit generates all three executors, and the four tests execute every call site:
Qubit extension initialized - Call sites: 3 | Query executors: 3 generated, 0 deduplicated
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSQubit 1.0.0 also prints scanner warnings for synthetic $deserializeLambda$ methods. These warnings say that individual lambda fragments have no terminal operation. With debug logging enabled, Qubit can also warn that it could not load QubitEntity bytecode, and then enhance the application entity successfully. In this build, the final three-of-three executor count and the passing tests confirm that the real call sites were generated and executed. Keep the warnings visible and use the final count and tests to find a missing executor.
The hotspot test sorts the two tied service names before comparing them. The database query puts the three-issue group first. It has no second sort field, so the two-issue groups can appear in either order.
Decide where Qubit fits
Qubit 1.0.0 fits applications with a small number of known query shapes. The lambdas keep entity field access readable and visible to Java refactoring tools. Captured filters, DTO projections, aggregates, grouping, and scalar subqueries cover many common read queries.
For the current preview release, I would use these controls:
Pin the Qubit and Quarkus versions together. Preview APIs and generated behavior can change.
Execute every Qubit call site in tests.
fail-on-analysis-errordoes not catch the generation failure shown above in 1.0.0.Keep request-driven values bounded. Captured parameters handle binding. Limits still control query cost.
Check Hibernate SQL for important queries and use the database’s execution-plan tooling when performance matters.
Keep schema creation in dev and test profiles. Production should use migrations and
validate.Use direct, supported lambda expressions. A helper that receives the entity can move the query outside Qubit’s supported expression set.
Use another query mechanism when users define the query structure at runtime.
Qubit accepts a limited set of Java expressions. That limit lets Quarkus understand the lambda and generate the database operation during the build. In ReleaseRadar, three real persistence rules stay short enough to review as Java, and PostgreSQL still performs the queries. For Qubit 1.0.0, my production decision would depend on complete query tests because one unsupported generation path can leave the Maven build green.



