Read-only transactions for reliable Grails services
Reporting screens, background jobs and API endpoints often spend most of their time reading data. In a Grails application, that work belongs in a service, where transaction boundaries can be declared consistently. A read-only transaction records a clear intention: the unit of work will query persistent state and will not change it. The annotation is small, but its effect depends on transaction propagation, ORM behaviour and the database engine beneath GORM.
@Transactional(readOnly = true) passes read-only metadata through Spring’s transaction layer to Hibernate. The ORM can suppress automatic flushing and avoid unnecessary dirty-checking work, while some database engines can optimise a read-only unit of work. This is more than a label for lookup methods: it communicates intent, helps reviewers spot accidental writes and creates a stable boundary around report queries.
Read-only is not immutability, however. It is transaction metadata and usually a persistence-layer hint, not a universal database lock. Code can still execute native updates, and a method may join a transaction that was already opened for writing. It also does not repair N+1 queries or missing indexes. Use the annotation with strict service boundaries, deliberate query design and integration tests against the production database engine.
The pattern below starts with a typical Grails service, then covers annotation scope, propagation, mappings, testing and operations. The examples use Groovy syntax familiar to Java developers and focus on transaction behaviour rather than framework setup. The aim is code that remains clear when a report grows, a job calls the service, or the application moves between a local server and an Australian cloud region.
Understand the transaction boundary
A service method is a natural transaction boundary because it represents one business operation. Spring’s transaction interceptor opens the transaction before the method runs and completes it after the method returns or throws. When readOnly is enabled, that boundary carries read-only metadata into the ORM session. Controllers should normally call the service rather than own this policy, since several entry points can then share the same rule.
For example, this query belongs in grails-app/services:
package billing
import grails.gorm.transactions.Transactional
class LedgerService {
@Transactional(readOnly = true)
List<LedgerEntry> entriesBetween(Date from, Date to) {
LedgerEntry.where {
postedAt >= from && postedAt <= to
}.list()
}
}
The where closure becomes parameterised GORM SQL, and the method never changes a domain object. It is still important to keep validation and command operations in separate services: a read-only annotation documents the expected behaviour but does not turn arbitrary repository calls into safe queries.
Automatic flushing matters because Hibernate may otherwise inspect changed entities when a transaction commits. In read-only mode that work is usually unnecessary. The benefit grows for reports that load many entities, although the largest gains often come from fetching only required columns and avoiding repeated lazy queries. Treat the annotation as one part of query performance, not a replacement for indexing.
Annotate methods deliberately
Annotate the smallest useful scope. A class-level transaction establishes one policy for all operations, while a method-level annotation can make only lookup methods read-only. The more specific method declaration determines that invocation’s transaction attributes, so a command method can retain write access inside an otherwise read-only service.
import grails.gorm.transactions.Transactional
@Transactional
class CustomerService {
@Transactional(readOnly = true)
List<Customer> activeCustomers() {
Customer.findAllByActive(true)
}
@Transactional
Customer changeStatus(Long id, String status) {
Customer customer = Customer.get(id)
if (!customer) {
throw new IllegalArgumentException("Customer not found")
}
customer.status = status
return customer.save(failOnError: true)
}
}
Keep the distinction visible in names and return types: methods such as activeCustomers or exportTotals return information, whereas changeStatus or archiveInvoice expresses a command. A method that both reads and writes should be treated as writable, even if most of its work consists of queries.
Spring proxies also matter. Calling a transactional method directly on this bypasses the proxy, so its annotation may not create a new boundary or apply separate propagation rules. Put cross-service operations in another service, inject the collaborator, or call through the proxy. This distinction is easy to miss when a private helper quietly performs a write.
Keep writes outside read paths
Create a hard boundary between query and command flows. Read methods should return immutable summaries or DTOs rather than managed domain instances that a controller might modify later. This also prevents lazy relationships from being accessed after the transaction has closed. Where clients need related data, load the required associations while the read-only transaction is active or build the complete projection inside the service.
Write operations should use a separate, writable method and service. A typical command might load an entity, apply validation, assign the new state and let GORM flush it at commit. Do not rely on readOnly to reject a write: behaviour varies by ORM configuration, native SQL and database driver, and an accidental update should fail review rather than depend on a subtle runtime exception.
Propagation can also affect the guarantee. With the default REQUIRED behaviour, a read-only method joins an existing read-write transaction and may inherit its writable characteristics. REQUIRES_NEW can suspend that transaction and start an isolated read-only unit of work, at the cost of another database connection. Use it only when isolation from the caller is necessary.
Australian teams handling customer information should also check the Privacy Act 1988 and Australian Privacy Principles where they apply. The Grails Example privacy policy shows how a project can state what it collects and why, and that level of clarity should carry into export and logging decisions.
Match read-only semantics to the data layer
Database support varies. PostgreSQL can enforce read-only transaction characteristics, while other engines may use the connection setting mainly as an optimisation or ORM instruction. Schema privileges still matter: grant the reporting role SELECT only if writes should be impossible at the database boundary. Hibernate’s transaction flag should complement least-privilege access rather than replace it.
Design the query for the result shape. Use projections for aggregate reports, fetch joins for data that must be serialised, and indexes for filters and sort columns. A read-only transaction does not prevent the N+1 query pattern, and entities carrying many lazy properties can still consume significant memory. If a report needs a stable snapshot, choose an isolation level deliberately; read-only does not mean repeatable read.
Document databases require a different mental model. MongoDB transactions and read concerns are not interchangeable with JDBC isolation levels, and a read-only ORM flag does not create the same guarantees across drivers. The guide to Grails with MongoDB shows how persistence changes when the application moves away from a relational database, so confirm transaction support for the chosen MongoDB deployment before copying this pattern.
Prove the contract with integration tests
Test this behaviour with an integration test, not only a mocked service unit test. A mock can confirm that a method was called, but it cannot reveal whether Hibernate flushed an entity, whether the method joined an existing transaction or whether the database rejected an update. Run the test against the same engine used in production when possible; Docker or Testcontainers can make that practical on a developer laptop.
Cover at least three cases: the query returns the expected result without update SQL, a command method persists an allowed change, and a read-only method called within a writable transaction behaves as intended. For the last case, assert the actual transaction state or database outcome rather than assuming the annotation wins. Also test empty result sets, date boundaries and large result sets, because these often expose mapping and connection-lifetime issues.
Australian financial examples make useful fixtures. Use Australian dollars, GST-inclusive totals and a financial year beginning on 1 July where the domain calls for them, while keeping production currency and tax handling configurable. Teams in Brisbane and Perth often schedule batch reports around UTC, whereas Sydney and Melbourne offices shift clocks during daylight saving; predictable timestamps make failures easier to compare across regions.
Apply the pattern in production
Operational monitoring should distinguish read transactions from slow queries. Track transaction duration, connection-pool waits, rows returned and SQL statements separately, then alert on trends rather than a single slow report. Logs should omit unnecessary customer details and payment information. A redacted correlation identifier can connect a controller request, service invocation and database trace without copying personal data into every message.
Many Australian teams run Grails on Linux hosts or containers before moving to managed infrastructure. Use the Linux server guidance to align service management, file permissions and resource limits with the application’s needs. A runbook entry saying the “arvo” deploy looked clean still needs exact timestamps, versions and rollback steps; “no worries” is fine for a routine handoff, not for a failed migration.
Cloud deployment adds regional choices. Australian workloads often select Sydney or Melbourne regions for lower latency and data-residency alignment, but teams must still test the availability and support model of the selected database. Services spread across Perth and eastern states should use UTC internally and local time only in reports and schedules. Connection limits can multiply quickly when read-only methods fan out to several downstream services.
Apply the pattern now by choosing one reporting service, marking its query methods with @Transactional(readOnly = true), moving writes into explicit command methods and adding integration tests for both paths. Deploy that change to a non-production environment, inspect the SQL and transaction logs, then promote it once the read-only contract is visible in production telemetry.