Detached criteria for reusable Grails queries
Controllers often start with a small GORM query: one filter for a title, another for publication status, and a sort chosen from a request parameter. As an application grows, those fragments tend to spread through controllers, services, jobs and tests. Keeping database rules in one place makes behaviour easier to understand, especially when several teams consume the same catalogue.
Detached criteria provide a way to define a query separately from the session or transaction that will run it. A method can construct the criteria, apply optional conditions and hand it back for later execution. That makes the definition reusable without forcing callers to know the table structure or repeat a chain of filters.
The useful design is not a universal query object shared by every thread. It is a small query factory that creates fresh criteria for each request, plus services that execute those criteria safely. The examples below use a video catalogue because episode, title and market filters make the trade-offs visible while remaining familiar to any Grails application.
Why detached criteria suit shared query logic
An attached criteria is created in the context of the current persistence session, which is convenient for a request that immediately loads data. A detached criteria has its own identity and can be assembled without depending on that request-scoped context. It can therefore move from a service to a reporting job or another execution point without carrying the original session around.
“Detached” describes the criteria definition rather than changing the lifecycle of every object returned by it. Once results are loaded, normal GORM rules still apply for lazy associations, dirty checking and transaction boundaries. The main benefit is separation: query construction can be organised independently from the code that opens a transaction, performs the query and formats the result.
This separation helps when the same rule has different consumers. An admin screen may show all drafts, a public API may expose only published episodes, and a scheduled report may need a wider date range. All three can call named query-building methods, while their services decide which filters to add and how many records to return. The result is less accidental variation than copying createCriteria chains and editing them at each call site.
Building a reusable criteria definition
Modern Grails applications can create a detached criteria with DetachedCriteria.where. Older Grails releases may expose a constructor or a domain-class factory such as newDetachedCriteria(), so the exact creation call should match the project version. The rest of the design remains the same: begin with the domain type and add only the conditions relevant to the query.
import grails.gorm.detached.DetachedCriteria
DetachedCriteria<Episode> episodeFilter(Map filters) {
DetachedCriteria<Episode> criteria = DetachedCriteria.where(Episode)
String term = filters['term']
if (term) {
criteria.like('title', "%${term}%")
}
if (filters['genre']) {
criteria.eq('genre', filters['genre'])
}
if (filters['market']) {
criteria.eq('market', filters['market'])
}
return criteria
}
Each call creates a new criteria instance. That detail matters because criteria builders are mutable: adding a condition to a shared singleton would make one caller’s filters leak into another. For Java developers coming from typed repositories, the Groovy map access and optional if blocks may look compact, but the parameters remain bound as values rather than being concatenated into HQL.
The method should also define what “contains” means. The example adds SQL wildcard characters around the search term and is suitable for a normal title search. If a literal percent sign must be matched, it needs escaping according to the database’s wildcard rules. Keeping wildcard policy in the query factory prevents a request parameter from unexpectedly changing the shape of the search.
Turning query definitions into a catalogue
A query catalogue can be a Groovy class containing static factory methods such as publishedEpisodes(), availableInMarket() and recentEpisodes(). Give each method a business-readable name and a small, explicit input object or map. The method returns criteria; it does not execute the query. Execution then belongs to a service that can apply transaction and authorisation rules.
Do not make detached criteria a synonym for “no session required” or “safe by default”. They are valuable because they separate definition from execution, but callers still need control over execution, permissions and error handling. A catalogue that exposes dozens of nearly identical methods is also difficult to maintain. Group closely related filters, document the defaults, and keep presentation-specific concerns such as pagination defaults out of the reusable definition.
Documentation is part of that catalogue. When an Australian team maintains notes for Arabic-speaking developers as well, a resource such as multilingual editorial guidance can sit alongside the internal examples. The important practice is to show the same named query, its accepted values and a representative result, rather than expecting every developer to infer intent from a long method chain.
Executing criteria safely in services
Execution should happen in a service or repository, usually inside a transaction when the work continues with changes to loaded entities. The service receives the caller’s filters, asks the query catalogue for fresh criteria, and then runs list(), count() or get(). This keeps controllers thin and gives one place to enforce limits, ordering and access rules.
@Service
@Transactional
class EpisodeService {
List<Episode> search(Map filters) {
DetachedCriteria<Episode> criteria =
EpisodeQueries.availableInMarket(filters['market'])
if (filters['term']) {
criteria.like('title', "%${filters['term']}%")
}
return criteria.list(max: 20, offset: 40)
}
}
The values passed through the criteria DSL are parameterised, which avoids the classic mistake of inserting request text into HQL. Even so, validate enum-like inputs, cap page sizes and use an allow-list for sortable fields. Do not accept a property name directly from the client; map a public value such as newest to a known domain property before ordering.
A count often needs the same filters but different projection and ordering. Build it from the same factory and execute it separately, as in EpisodeQueries.availableInMarket('AU').count(). This avoids selecting every column merely to count rows. Keep transactional boundaries clear, and remember that lazy associations may trigger extra queries after the main list has loaded.
Combining filters, projections, and pagination
A reusable filter is rarely the whole request. Callers may need a projection for a compact table, a sort order and a bounded page. Those concerns can be layered onto the criteria returned by the catalogue, provided the caller receives a fresh instance. The catalogue owns domain rules; the service owns request-specific presentation choices.
DetachedCriteria<Episode> criteria =
EpisodeQueries.availableInMarket('AU')
criteria.select('title')
criteria.order('title', 'asc')
List rows = criteria.list(max: 20, offset: 40)
A projection may produce scalar values or maps rather than full Episode instances. That is useful for display rows, but it changes the available data: code expecting episode.channel.name will not have that property after projection. Use a small data-transfer object when the response contract deserves typed fields. Likewise, test queries that fetch related records, because a page of twenty episodes can otherwise trigger many lazy-load queries.
Pagination needs deterministic ordering. Sort by the requested field and add a unique tie-breaker, such as the episode identifier, so rows do not move between pages when several items share a timestamp. Apply a maximum page size in the service, and use a cursor or keyset pagination for large, frequently changing catalogues rather than relying on a deep offset.
Applying detached criteria to an Australian streaming app
Imagine a streaming catalogue built for Australian viewers. Episode might contain a title, genre, publication date, market code, Australian price and channel reference. The query catalogue can expose named filters for published episodes, local productions and titles available in the Australian market. For the surrounding plugin setup, the video streaming plugin guide provides a practical companion to this kind of domain model.
Australian content does not come from one city. Episodes can be tagged for Sydney, Melbourne, Brisbane, Perth or Adelaide, with state codes such as NSW, VIC, QLD, WA and SA used for reliable filtering. Local slang can become part of editorial metadata too: an “arvo” collection or a “fair dinkum picks” row may appeal to Australian audiences, while ordinary language searches still need sensible normalisation and case-insensitive matching.
Commercial filters need local precision. Store prices as BigDecimal in Australian dollars, record whether the displayed price includes the 10% GST, and avoid binary floating-point values for comparisons. A query might distinguish GST_INCLUDED from GST_EXCLUDED, while the checkout service applies the correct tax treatment. This keeps catalogue filtering aligned with how subscriptions and one-off purchases are offered in the Australian market.
Time handling adds another local detail. Sydney and Melbourne use AEST or AEDT, while Perth uses ACST or ACDT, so release timestamps should be stored in UTC and converted for display. Australian dates are commonly written as day, month and year, such as 08/03/2026, while API fields should use an unambiguous ISO 8601 value. A reusable query can then filter a UTC cutoff consistently and let the interface localise both the timestamp and date format.
Start by moving one duplicated criteria chain into a named factory method. Create fresh detached criteria for each call, execute them from a Grails service, and add tests for the filters that matter to the business. The Grails Example written lessons and video tutorials can guide the next step as that small catalogue grows into a dependable data-access layer.
Create the first named query, exercise it from a service and turn it into the foundation of your Grails data-access layer.