Pessimistic Locking in Grails GORM for Reliable Data Writes

Two shoppers can submit checkout requests for the same limited item at almost the same instant. If both Grails transactions read available stock, calculate a new balance and write it back, the second write may silently replace the first. The result is overselling, a double allocation or an inventory total that no longer matches the orders already created.

Pessimistic locking prevents that race by making the database reserve the relevant row while the transaction is active. Grails GORM can request this write lock through Hibernate, while the transaction manager keeps it in place until commit or rollback. The pattern is especially useful for scarce stock, account balances, seat reservations and other read-modify-write operations where a stale value cannot be accepted.

Why Concurrent Writes Need Database Locks

Consider an Australian online retailer whose flash sale is managed in Sydney while customers also order through its Melbourne store. A request for the final unit reaches Grails at the same time as another request. Both application instances may serve different users, yet they share one database. A lock held inside one JVM cannot protect the other instance; a database row lock can coordinate all of them, including scheduled jobs and admin tools.

With optimistic locking, a domain class with a version property detects conflicting changes and asks the caller to retry. That approach works well when conflicts are uncommon. Pessimistic locking takes a different path: the first transaction locks the row, the second waits, and the second operation then evaluates current state. It suits high-contention updates where repeating the user's request would be confusing or where the business rule requires an immediate answer.

Choose the narrowest unit of protection. Locking a whole product catalogue for the duration of checkout is safe but wasteful, while locking one inventory row for a short update reduces contention. The transaction boundary must cover the read, decision and write as one unit. If code releases the lock before persisting the changed value, the protection has already disappeared.

How Row-Level Locking Protects a Transaction

Under a typical database dialect, the locking read becomes a statement similar to SELECT ... FOR UPDATE. The row is marked as held by that transaction, and other transactions either wait or receive a lock-timeout error, depending on database configuration. The lock is released only after commit or rollback. A GORM flush sends SQL earlier, but it does not release the row lock before the surrounding transaction ends.

Pessimistic write locking normally applies to the row already selected. It prevents two transactions from updating that same record at once, but it does not automatically stop a query from seeing new rows inserted by another transaction. That distinction matters under isolation levels such as Read Committed or Repeatable Read. If a rule depends on a stable set of rows, use an appropriate isolation level or design the query around keys that already exist.

A cart containing several products needs a consistent acquisition order. If one request locks product A then B, while another locks B then A, both can wait for each other and the database may declare a deadlock. Sorting product identifiers before locking reduces this risk. It also helps to avoid network calls, payment requests and large file work while a row is locked.

Locking also has boundaries. It cannot protect a row that does not exist yet, so two simultaneous creation attempts still need a unique constraint on the business key. Nor does it coordinate work in another database, message broker or cloud service. Those cases need an idempotency key, an outbox pattern or another consistency mechanism.

Building a Transactional Grails Service

Put the operation in a service-layer transaction rather than in a controller. The following example uses a dynamic finder with GORM's lock option so the row is selected under a pessimistic write lock. Confirm the exact API and generated SQL for your Grails and Hibernate versions; some releases expose the same operation through lock() or an explicit PESSIMISTIC_WRITE query.

import grails.transaction.Transactional

class InventoryService {

    @Transactional
    Reservation reserve(String sku, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive")
        }

        Product product = Product.findBySku(sku, [lock: true])
        if (!product) {
            throw new IllegalArgumentException("Unknown SKU: ${sku}")
        }
        if (product.available < quantity) {
            throw new InsufficientStockException(sku)
        }

        product.available -= quantity
        product.save(flush: true, failOnError: true)

        Reservation reservation = new Reservation(
            sku: sku,
            quantity: quantity,
            status: 'RESERVED'
        )
        reservation.save(flush: true, failOnError: true)
        reservation
    }
}

The transaction starts before the locking finder runs and remains active through method completion and the final commit or rollback. flush: true makes the stock update visible within the transaction and surfaces some failures sooner, but commit or rollback remains the final release point. Do not catch a persistence exception and continue as though nothing happened: allow the transaction to roll back, then translate the error at the service boundary.

If a workflow does not fit declarative transactions, withTransaction can provide the same boundary around the locked read and write. Whichever style is used, invoke the transaction from another service or controller; a direct call from inside the same bean can bypass proxy-based interception. Keep remote payment calls, email delivery and file processing outside this boundary, because they prolong lock ownership without improving database consistency.

For advice on fitting this pattern to an existing Grails application, contact Grails Example with a minimal reproduction, the relevant database and the expected concurrency. In a multi-item reservation, lock the rows in the same sorted order used by every caller, and document the exception type that callers should receive.

Operational Safeguards for Locked Transactions

A pessimistic lock extends the lifetime of a database transaction, so operational discipline matters as much as the code. The following checks keep contention observable and bounded.

Before opening the transaction

The first list prevents avoidable waits. The second defines how the service should respond when the database reports contention. Both belong in runbooks as well as code.

When contention or failure occurs

Deadlock victims and timeout victims are not automatically safe to retry. Retry only idempotent work, keep the number of attempts limited and preserve the original business identifiers. Reproduce the race in integration tests against the same database engine used in production; H2 or an in-memory substitute may not reproduce its lock and timeout behaviour. A slow database, a growing pool or a sudden promotion can turn otherwise safe retries into a thundering herd.

Apply the Pattern to an Australian Retail Workflow

A practical Australian example is a national retailer reserving stock for online orders and click-and-collect. Its network may span Sydney, Melbourne, Brisbane and Perth, while its storefront shows GST-inclusive prices in Australian dollars, as local shoppers expect. The fulfilment pattern resembles the combined store and online inventory familiar from Coles and Woolworths, although any retailer promising an item at payment faces the same concurrency problem.

Use the lock for the inventory reservation and create the order record in the same database transaction, then release both before calling a payment gateway or courier. External services can be slow, so including them in the locked transaction would hold database resources unnecessarily. For teams adapting an international marketplace feed, the Arabic numeral checklist is a useful reminder to normalise order references and identifiers before querying Australian systems.

Dispatch timing also needs local awareness. A warehouse cut-off may use AEST or AEDT depending on daylight-saving changes, and an Australia Post label should not be created until the reservation has committed. If a downstream integration fails, a reconciliation job can release the reservation according to a business rule. That job must acquire the same row lock so it cannot race with a customer checkout.

Start with one measurable workflow, such as eliminating a final-unit oversell in staging. Reproduce the race with concurrent requests, implement the lock in the narrowest service transaction, and verify committed stock against reservation and order records. Then load-test the same path against the production database engine before enabling it for live traffic.