Run Grails Events After a Successful Commit

Transactional events give a Grails application a safer boundary between changing business data and reacting to that change. For a Melbourne-based online retailer handling an Australia Day promotion, saving an order and requesting fulfilment appear to be one operation to a customer. Internally, they are different: a database rollback can erase the order while an email has already escaped. Grails transactional event listeners let you defer that reaction until the surrounding transaction reaches the expected outcome.

The key requirement is transaction awareness. A regular event listener reacts as soon as it receives an event, even if the publishing method later rolls back. A transaction-aware listener participates in the transaction lifecycle, allowing Grails to invoke it after commit, before commit, after rollback, or after either outcome, depending on its configured phase. This makes the pattern useful for order notifications, stock adjustments, audit records and downstream fulfilment.

The mechanics are manageable, but the reliability questions matter just as much. Publishing from the correct service method establishes the boundary; choosing a phase defines when the callback runs; separating database work from external calls avoids false assumptions about atomicity; and integration tests must exercise both success and failure. The examples below use a small order workflow from an Australian shop, with prices in Australian dollars and a GST invoice request associated with a committed order.

Why Transactional Events Matter

When a controller calls a service that changes an order, the service usually owns a database transaction. Publishing an ordinary event inside that method tells listeners to act immediately. If the method subsequently throws an exception, a listener might already have sent a confirmation, booked a courier or called a payment provider. The order disappears during rollback, but the external side effect remains.

A transaction-aware listener changes that timing. Instead of treating publication as the beginning of downstream work, the listener registers a callback associated with the current transaction. Grails can then invoke it when the transaction commits, when it rolls back, or at a related phase defined by the annotation.

Phase selection should describe business intent rather than convenience. AFTER_COMMIT is a sensible default for notifications and downstream fulfilment. BEFORE_COMMIT suits checks that must veto a commit, provided they avoid irreversible external calls. AFTER_ROLLBACK can record a failed attempt, while AFTER_COMPLETION runs after either outcome and suits cleanup that does not depend on success. Crucially, AFTER_COMMIT is outside the original transaction’s atomic boundary.

Define the Event and Listener

Start with a small event contract rather than passing a live domain object to a background listener. A value carrying the order identifier, total and customer email is easy to test and avoids accidental access to a detached persistence session.

package com.acme.shop

class OrderPlacedEvent {
    Long orderId
    BigDecimal total
    String email
}

Register the listener as a Grails singleton. The explicit phase documents its intended timing; omitting the phase also selects AFTER_COMMIT.

package com.acme.shop

import grails.events.TransactionPhase
import grails.events.annotation.TransactionalEventListener
import jakarta.inject.Singleton

@Singleton
class OrderNotificationListener {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void onOrderPlaced(OrderPlacedEvent event) {
        // Send the notification or start downstream fulfilment.
    }
}

Publish the event from the service method that owns the transaction. Publishing it after the service returns would be too late because the transaction would already have completed.

package com.acme.shop

import grails.events.Events
import grails.transaction.Transactional

@Transactional
class OrderService {
    private final Events events

    OrderService(Events events) {
        this.events = events
    }

    Order placeOrder(Order order) {
        order.save(failOnError: true)

        events.publishEvent(new OrderPlacedEvent(
            orderId: order.id,
            total: order.total,
            email: order.email
        ))

        order
    }
}

Grails discovers the listener through its Spring-managed components, so manual event registration is unnecessary. The listener runs when the transaction completes successfully, not when publishEvent is called. The Grails Example tutorials provide useful reference points for aligning service structure, package layout and event conventions with a working Grails application.

Keep Commit and Publish Separate

The transaction boundary belongs on the service, not on the event publisher itself. In the example, @Transactional keeps the order change and event publication inside one unit of work. A controller should call that service and handle the returned result rather than opening its own transaction and publishing an event separately. Self-invocation also deserves attention: calling a transactional method from another method in the same bean can bypass the proxy that applies transaction behaviour.

An AFTER_COMMIT callback no longer has the original order transaction around it. It cannot veto the completed purchase or make the original order change roll back. If the listener must create a separate database record, call another Spring-managed service and give that operation its own transaction.

package com.acme.shop

import jakarta.inject.Singleton
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional

@Singleton
class FulfillmentService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    void requestFulfilment(OrderPlacedEvent event) {
        FulfillmentRequest.create(
            orderId: event.orderId,
            status: 'PENDING',
            total: event.total
        )
    }
}

REQUIRES_NEW gives the fulfilment request a separate commit boundary, which is useful when it must be persisted independently of the already committed order. It does not restore atomicity between the two operations. If the order commits and the new transaction fails, the customer still has an order without a fulfilment request. For a delivery to a Sydney address, a durable outbox is generally more dependable than relying on an in-memory callback alone.

A BEFORE_COMMIT listener can perform validation while the transaction is still active. If it throws an exception, the transaction can roll back, including database changes made by that listener. It should not send an email or call a courier before the outcome is known, because the transaction may still fail afterwards. Reserve irreversible external calls for the committed phase or a durable work queue.

Design Retries Around Delivery Guarantees

A commit callback provides correct timing, but it does not guarantee durable delivery. The application process could terminate after the database commits and before the listener completes. Retrying an AFTER_COMMIT listener is also awkward because the original transaction has already succeeded; an exception at that point cannot undo the order.

For fulfilment that must survive a crash, write an outbox record inside the same database transaction as the order. The record expresses pending work without depending on an external service remaining available during the commit.

FulfillmentRequest.create(
    orderId: order.id,
    type: 'ORDER_PLACED',
    status: 'PENDING',
    total: order.total
)

A scheduled worker can then find pending requests, perform the downstream action and mark each request as processed. Because the order and its request share a transaction, they either both commit or both roll back. A unique constraint on the order identifier and request type can prevent duplicate rows. Even so, a worker might send a courier booking and crash before recording success, so the downstream operation should accept an idempotency key where possible.

Retries need boundaries as well as backoff. A failed call to an email provider or courier service should have a limited number of attempts, increasing delays between attempts and recording the last failure. After those attempts are exhausted, the request needs an operational status such as FAILED rather than remaining invisibly pending. Metrics for pending age, processing duration and failure counts provide a clearer picture than application logs alone, particularly when a promotional weekend produces a sudden queue of Australian-dollar fulfilment requests.

Verify Both Commit and Rollback

Use a Grails integration test that drives a real service transaction and the real event infrastructure. A mocked publisher can confirm that a method was called, but it cannot prove when the listener executed relative to the commit. A rollback-only test is useful for the failure path, yet it cannot establish that an AFTER_COMMIT listener ran successfully. Keep one test that commits and cleans up its data deliberately, alongside tests that force a rollback.

The committed case should assert that the order exists and that the expected downstream work has started. For the shop workflow, that could mean a fulfilment request exists for the order and that its GST invoice request carries the correct Australian-dollar total. Also check the listener’s observable effect rather than only the absence of an exception, since a swallowed failure could leave the transaction successful but the notification missing.

The rollback case should publish the event and then deliberately fail the transaction. An AFTER_COMMIT listener must not run for that order. A BEFORE_COMMIT listener may have run, but any database changes it made must disappear with the rollback. An AFTER_ROLLBACK listener should run only on the failure path, while an AFTER_COMPLETION listener should run in both scenarios.

Test repeated delivery and unavailable external services separately. A retry test can simulate a transient timeout, while an idempotency test can confirm that the same order does not create duplicate fulfilment requests. Keep event payloads limited to stable values such as identifiers and totals; loading a mutable domain object inside a callback can produce stale data or detached persistence errors. These checks turn transaction timing into an observable application guarantee.


Pick one existing Grails workflow and add a single domain event to it. Publish that event inside a transactional service method, attach an AFTER_COMMIT listener and write an integration test proving that the callback is suppressed when the transaction rolls back. Keep external calls behind a separate service boundary, and use a durable outbox for work that must survive a process restart.

Treat the commit test and the rollback test as one feature. Run both whenever the publisher, phase or listener changes, then extend the pattern to other business events once its guarantees are visible in the build. Make the next Grails change react to the database outcome, not merely to a method call.