This guide gives step-by-step instructions for AI agents and developers to write application queries using Ebean query beans.
Use this guide when the project already has Ebean configured and you need to:
- add a repository/service query
- replace string-based ORM queries with type-safe query beans
- tune what data is fetched to avoid over-fetching or N+1 issues
- return DTO projections for list screens or API responses
The default recommendation is:
- Prefer query beans first
- Prefer entity queries for domain logic
- For read-only entity graphs, prefer
setUnmodifiable(true) - Prefer DTO projection for summary/read-model use cases
- Only drop to raw SQL when the ORM query cannot express the requirement cleanly
- The project already uses Ebean ORM
- Query bean generation is configured (for Maven this usually means
querybean-generatoris registered as an annotation processor) - Entity beans already exist
- A compile/build has run successfully since the last entity model change
If query beans are not yet configured, first follow:
add-ebean-postgres-maven-pom.md
For each entity bean, Ebean generates a query bean with the same name prefixed
with Q.
Examples:
Customer->QCustomerOrder->QOrderContact->QContact
Import the generated type from the query bean package:
import org.example.domain.query.QCustomer;If the Q* type does not exist or the IDE cannot resolve it:
- Confirm the entity compiled successfully
- Run a normal project compile/build
- If the entity was renamed or moved, run a full rebuild rather than relying on incremental compilation
After refactoring an entity name, old generated query beans can remain on disk
until the next full build. If both old and new Q* types appear to exist, do a
clean rebuild before editing application queries.
Decide what the caller actually needs. This determines the terminal method and often the right query shape.
| Need | Preferred method | Notes |
|---|---|---|
| Check if at least one row exists | exists() |
Cheapest choice for boolean existence checks |
| Load exactly one row by ID or unique key | findOne() |
Only use when the predicate is truly unique |
| Load a list of entity beans | findList() |
Default for list screens and domain logic |
| Count matching rows | findCount() |
Prefer over loading entities just to count |
| Load a page plus optional total row count | findPagedList() |
Use when the caller needs pagination metadata |
| Return DTO/read-model rows | asDto(...).findList() |
Prefer this over partially loaded entities for API/view models |
boolean alreadyUsed = new QCustomer()
.email.equalTo(email)
.exists();Customer customer = new QCustomer()
.email.equalTo(email)
.findOne();Do not use findOne() for predicates that can match multiple rows.
With query beans, write predicates directly against properties. When you traverse an association, Ebean adds the necessary joins automatically.
List<Customer> customers = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.name.istartsWith("rob")
.findList();List<Customer> customers = new QCustomer()
.billingAddress.city.equalTo("Auckland")
.findList();List<Customer> customers = new QCustomer()
.contacts.isEmpty()
.findList();When adding a new query:
- Start from the root entity that the caller wants back
- Add predicates with query bean properties
- Traverse relationships instead of writing manual join SQL
- Keep property references type-safe; avoid string property names unless the API specifically requires them
Do not leave list queries unordered unless the call site truly does not care. For UI lists, APIs, and background jobs, explicit ordering is usually better.
List<Customer> customers = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.orderBy().name.asc()
.setMaxRows(50)
.findList();List<Customer> customers = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.orderBy().id.asc()
.setFirstRow(offset)
.setMaxRows(pageSize)
.findList();PagedList<Customer> page = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.orderBy().id.asc()
.setFirstRow(offset)
.setMaxRows(pageSize)
.findPagedList();
page.loadRowCount();
List<Customer> customers = page.getList();
int totalRowCount = page.getTotalRowCount();- Use
findList()when the caller only needs rows - Use
findPagedList()when the caller also needs page metadata or total counts - Pair pagination with a stable
orderBy()so page boundaries stay predictable
By default, entity queries can load more of the object graph than the caller
actually needs. Use select() and fetch() to control the root and association
properties that are loaded.
Use select() to define which properties should be fetched on the root entity.
Use fetch() to define what should be fetched on associated paths.
private static final QCustomer CUST = QCustomer.alias();
private static final QContact CONT = QContact.alias();
List<Customer> customers = new QCustomer()
.select(CUST.name, CUST.status, CUST.whenCreated)
.contacts.fetch(CONT.email)
.name.istartsWith("rob")
.findList();In this example:
select(...)tunes the rootCustomerpropertiescontacts.fetch(...)tunes the associatedContactproperties- the query still returns
Customerentity beans
- Only use
select()/fetch()when you know what the caller will read next - Do not treat partially loaded entities like fully populated API DTOs
- If the caller only needs summary fields, prefer a DTO projection instead
setUnmodifiable(true) turns the returned object graph into an unmodifiable,
read-only graph.
This means:
- setters cannot mutate returned beans
- associated collections are unmodifiable
- lazy loading is disabled
- accessing an unloaded property throws
LazyInitialisationException - the query uses
PersistenceContextScope.QUERY
private static final QCustomer CUST = QCustomer.alias();
private static final QContact CONT = QContact.alias();
List<Customer> customers = new QCustomer()
.select(CUST.name, CUST.status, CUST.whenCreated)
.contacts.fetch(CONT.email)
.status.equalTo(Customer.Status.ACTIVE)
.setUnmodifiable(true)
.findList();Use it when the result is meant to be read-only, such as:
- service/query methods returning entity graphs for display or serialization
- query results you want the application to treat as immutable
- cached query results or other shared read models backed by entity graphs
- partial entity graphs where you want accidental lazy loading to fail fast
Do not use setUnmodifiable(true) when the caller will:
- modify the beans and save them later
- rely on lazy loading of associations or unloaded scalar properties
- treat the result as a working persistence model rather than a read-only view
If you are returning entity beans for read-only use, setUnmodifiable(true)
should be the default recommendation. If the caller needs a mutable model or a
serialized summary shape, choose mutable entities or DTO projection instead.
Ebean applies important SQL rules when translating ORM queries:
- It does not generate SQL cartesian products
- It honors
maxRowsin SQL
This means to-many paths often need special handling.
- the query includes a
OneToManyorManyToManypath - the query includes
setMaxRows(...) - the query loads multiple to-many paths
- you want the query shape to make the secondary-query behavior explicit
private static final QCustomer CUST = QCustomer.alias();
List<Order> orders = new QOrder()
.customer.fetch(CUST.name)
.lines.fetchQuery()
.shipments.fetchQuery()
.status.equalTo(Order.Status.NEW)
.setMaxRows(100)
.findList();- the same fetch shape is reused in multiple places
- you want to separate predicate logic from fetch-shape tuning
- you want an immutable, static query-shape definition
private static final QCustomer CUST = QCustomer.alias();
private static final FetchGroup<Customer> CUSTOMER_SUMMARY =
QCustomer.forFetchGroup()
.select(CUST.name, CUST.status, CUST.whenCreated)
.billingAddress.fetch()
.buildFetchGroup();
List<Customer> customers = new QCustomer()
.select(CUSTOMER_SUMMARY)
.status.equalTo(Customer.Status.ACTIVE)
.findList();If the caller needs multiple to-many paths or a paged query, be suspicious of a
plain fetch(...) on those paths. fetchQuery() is often the safer default.
For list screens, API summaries, exports, or read-model views, the caller often
does not need managed entity beans. In those cases, project directly to a
DTO using asDto(...).
import static org.example.domain.query.QCustomer.Alias.id;
import static org.example.domain.query.QCustomer.Alias.name;
public record CustomerSummary(long id, String name) {}
List<CustomerSummary> summaries = new QCustomer()
.select(id, name)
.status.equalTo(Customer.Status.ACTIVE)
.orderBy().name.asc()
.asDto(CustomerSummary.class)
.findList();- the caller will serialize the result directly
- only a subset of fields is needed
- the result is not going to be updated and saved back as an entity
- the query contains formulas or aggregation intended for a read model
Prefer the following order:
- Query bean query
- Query bean query +
asDto(...) DB.findDto(...)or DTO query- Native SQL /
SqlQuery/RawSql
- vendor-specific SQL that query beans do not express well
- advanced aggregation or database functions
- hand-tuned reporting queries
- stored procedures or raw JDBC workflows
Do not jump to raw SQL just because the query joins multiple tables. Query beans already handle ordinary relationship traversal well.
Avoid:
List<Customer> customers = DB.findNative(Customer.class,
"select c.* from customer c join address a on a.id = c.billing_address_id where a.city = ?")
.setParameter(1, city)
.findList();Prefer:
List<Customer> customers = new QCustomer()
.billingAddress.city.equalTo(city)
.findList();Avoid:
Customer customer = new QCustomer()
.status.equalTo(Customer.Status.ACTIVE)
.findOne();Why: Many rows can match; this is not a unique lookup.
If the caller only needs summary fields, return a DTO instead of partially loaded entities that might later trigger more loading or confuse serializers.
If the caller is only meant to read the result, prefer setUnmodifiable(true)
so accidental setter calls, collection mutation, and lazy loading fail fast.
Do not eagerly fetch large object graphs unless the immediate caller will use them. Query tuning is part of the job.
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot resolve symbol QCustomer |
Query bean generation not configured or build not run | Check the annotation processor and run a build |
Old Q* class still appears after entity rename |
Stale generated source/class output | Run a clean rebuild |
findOne() fails because multiple rows match |
Predicate is not unique | Use findList() or tighten the predicate |
| Returned entities only have some fields loaded | select() or FetchGroup limited the query shape |
Add the required fields or switch to DTO projection |
| Setter calls or collection mutation fail on query results | setUnmodifiable(true) returned a read-only graph |
Remove setUnmodifiable(true) or treat the result as read-only |
Accessing an unloaded property throws LazyInitialisationException |
setUnmodifiable(true) disables lazy loading |
Fetch the property up front or use DTO projection |
| Ebean executes secondary queries for a to-many path | ORM rules avoided cartesian product or honored maxRows |
This is expected; use fetchQuery() explicitly when appropriate |
When asked to add or modify an Ebean query:
- Verify the relevant
Q*type exists - Choose the terminal method first (
exists,findOne,findList,findPagedList,asDto) - Add predicates with query bean properties and association traversal
- Add explicit ordering and pagination if relevant
- If the result is read-only entity data, consider
setUnmodifiable(true) - Tune the fetch shape with
select()/fetch()/fetchQuery()/FetchGroup - Prefer DTO projection for read models and serialized responses
- Only use raw SQL if the ORM query is genuinely the wrong tool