Show Navigation

Cross-Domain Logic in Grails with Transactional Spring Events

Fan out business logic across multiple domains after a domain object changes state, using Spring ApplicationEventPublisher and @TransactionalEventListener(AFTER_COMMIT) - the pattern that just works with GORM because GORM rides on Spring's PlatformTransactionManager.

Authors: James Fredley

Grails Version: 8

1 Getting Started

In this guide you will solve a problem every non-trivial Grails app eventually meets: how to fan out business logic across several unrelated domains after a single domain object changes state, without tangling those side-effects into the originating service and without firing them when the originating transaction rolls back.

The pattern - underused in Grails codebases - is Spring’s ApplicationEventPublisher combined with @TransactionalEventListener(phase = AFTER_COMMIT). Because GORM rides on Spring’s PlatformTransactionManager, the integration is essentially free: publish a plain Groovy event from a @Transactional service, declare any number of listener beans, and Spring buffers the events until the database commit lands. Roll back, and the listeners are silently skipped.

This guide targets Apache Grails 8 / Spring Boot 4 / JDK 21.

1.1 What You Will Build

By the end of the guide you will have:

  • A three-domain model - Customer, Order, and AuditLog - with Order belongsTo: Customer and a denormalised Customer.lifetimeValue field that downstream listeners maintain.

  • An immutable POGO event class OrderPlacedEvent under src/main/groovy/example/events/. No ApplicationEvent inheritance: Spring 4.2+ accepts any object as a published event.

  • An OrderService.placeOrder(…​) method annotated @Transactional that persists an Order and publishes an OrderPlacedEvent via an injected ApplicationEventPublisher.

  • Three independent listener services:

    • CustomerLifetimeValueService - bumps Customer.lifetimeValue after commit.

    • AuditService - writes an AuditLog row after commit.

    • NotificationService - dispatched off-thread via @Async after commit (a stand-in for sending email).

  • A Spock @Integration spec with two test methods that pin down the non-negotiable contract: AFTER_COMMIT listeners fire when (and only when) the publisher’s transaction commits, and are silently skipped when it rolls back.

  • A reference table of all four TransactionPhase values - BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION - explaining what each one is good for.

  • A side-by-side contrast with GORM’s domain-class lifecycle callbacks (beforeInsert / afterInsert) and Grails' @Subscriber / @Listener annotations, so you know which mechanism to reach for and when.

The second half of the guide builds the mirror-image pattern - cross-domain changes that must be atomic, sharing one transaction so they all commit or all roll back together:

  • A second three-domain model - CustomerRequest, WorkOrder, and WorkOrderAssignee - with a belongsTo chain and typed status enums.

  • A WorkOrderAssignmentService.assignEmployee(…​) that creates an assignment and publishes an EmployeeAssignedEvent from inside its @Transactional method.

  • A WorkOrderPlanningService annotated with a plain @EventListener (no transaction phase) that runs synchronously, inside the publisher’s transaction - reading in-flight state, cascading the WorkOrder to PLANNED and the CustomerRequest to IN_PROGRESS, and rolling all three writes back together on failure.

  • A Spock @Integration spec that proves the atomicity from both sides: all three changes commit as one unit on success, and a failure raised mid-orchestration leaves nothing persisted.

1.2 What You Will Need

To complete this guide you will need:

  • JDK 21

  • About 30 minutes

  • An IDE that understands Groovy (IntelliJ IDEA recommended)

1.3 How to Complete the Guide

You can either type the code in this guide as you read or skip ahead and clone the finished sample:

git clone -b grails8 https://github.com/grails-guides/grails-transactional-events.git
cd grails-transactional-events/complete
./gradlew integrationTest

initial/ is the vanilla Grails 8 starter from start.grails.org with the default H2 in-memory database. complete/ adds the Customer/Order/AuditLog domain model, the OrderPlacedEvent POGO, the OrderService publisher, the three listener beans, and the integration specs.

2 The Missing Pattern

Picture a checkout flow. OrderService.placeOrder(…​) persists an Order. Three other things need to happen "right after" - but only if the order actually commits:

  • The Customer.lifetimeValue field on the buyer needs to be incremented.

  • An AuditLog row needs to be written for compliance.

  • A confirmation email needs to go out.

The three obvious-but-wrong solutions:

Stuff every side-effect into OrderService.placeOrder(). The service now reaches into Customer, into AuditLog, into the email subsystem. Add a fourth side-effect later (a search-index update, a Slack webhook, a usage-counter increment) and you keep editing the same method. Every concern in the system gets a line in placeOrder. The "place an order" intent disappears under the maintenance work.

Use a GORM domain-class callback like Order.afterInsert. Closer, but two issues. The callback fires inside the Hibernate flush, not after the database commit. If the surrounding transaction rolls back later, the side-effects in afterInsert roll back too only if they used the same GORM session. Anything that touched an external system - the email send, the webhook - is already gone. Worse, GORM lifecycle callbacks live on the domain class itself, which has no Spring context, so calling other Spring beans from them is awkward and brittle.

Use a plain Spring ApplicationEvent (without the transactional flavour). This fires the listeners immediately when publishEvent(…​) is called - which happens inside OrderService.placeOrder(), before its surrounding transaction has committed. A rollback after publishEvent leaves phantom audit rows, phantom emails, and (depending on isolation level) listeners that read the to-be-rolled-back order from the database and act on data that will not durably exist.

The pattern this guide teaches is @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT). The publisher calls publishEvent(…​) unconditionally. Spring buffers the event in the transaction synchronization manager and dispatches it to listener beans only after the database commit succeeds. Roll back, and the listeners never see the event. Each side-effect lives in its own class. Adding a fifth side-effect means writing a fifth class, not editing OrderService.

Because GORM is implemented on top of Spring’s PlatformTransactionManager, the wiring is the wiring you already have - no plugins, no GORM events bus, no @EnableJpa* annotations. Spring sees the commit on the GORM session, fires the synchronisation callback, and dispatches the event.

Files touched in this chapter:

  • None

3 Creating the Application

Start from a vanilla Grails 8 application. The default web profile with the H2 in-memory database is enough; no extra features are needed for this guide.

The pattern itself has zero dependencies beyond what every Grails 8 starter already includes:

  • spring-context ships ApplicationEventPublisher, @EventListener, and PayloadApplicationEvent.

  • spring-tx ships @TransactionalEventListener and TransactionPhase.

  • grails-datastore-gorm-hibernate registers Spring’s HibernateTransactionManager as the PlatformTransactionManager Spring uses to fire AFTER_COMMIT callbacks.

Files touched in this chapter:

  • None

3.1 Download a Grails 8 Starter

Generate a Grails 8 starter from start.grails.org:

curl -sL "https://start.grails.org/grails/grails-transactional-events.zip?type=web&javaVersion=21&grailsVersion=8.0.0" -o grails-transactional-events.zip
unzip grails-transactional-events.zip -d grails-transactional-events
cd grails-transactional-events

You now have a Grails 8 / Spring Boot 4 / JDK 21 project ready to edit. The default web profile generates a GSP-rendered app; nothing else is required for the pattern.

Files touched in this chapter:

  • None (project generation only)

4 The Customer, Order, and AuditLog Domain Model

Three domains model the example. Customer carries a denormalised lifetimeValue. Order belongs to a Customer. AuditLog is a flat append-only record of business events.

grails-app/domain/example/Customer.groovy
package example

import grails.persistence.Entity

@Entity
class Customer {

    String name
    String email
    BigDecimal lifetimeValue = BigDecimal.ZERO

    static constraints = {
        name          blank: false, maxSize: 255
        email         email: true, unique: true, maxSize: 255
        lifetimeValue min: BigDecimal.ZERO
    }

    static mapping = {
        lifetimeValue scale: 2, precision: 19
    }

    String toString() { name }
}
grails-app/domain/example/Order.groovy
package example

import grails.persistence.Entity

@Entity
class Order {

    Customer customer
    BigDecimal total
    Date dateCreated

    static belongsTo = [customer: Customer]

    static constraints = {
        total min: new BigDecimal('0.01')
    }

    static mapping = {
        // 'order' is reserved in most SQL dialects
        table 'orders'
        total scale: 2, precision: 19
    }
}
grails-app/domain/example/AuditLog.groovy
package example

import grails.persistence.Entity

@Entity
class AuditLog {

    String eventType
    Long orderId
    Long customerId
    BigDecimal orderTotal
    Date occurredAt

    static constraints = {
        eventType  blank: false, maxSize: 64
        orderId    min: 1L
        customerId min: 1L
        orderTotal min: new BigDecimal('0.01')
        occurredAt nullable: false
    }

    static mapping = {
        orderTotal scale: 2, precision: 19
    }
}

A few choices to point at:

  • Customer.lifetimeValue defaults to BigDecimal.ZERO and is constrained min: BigDecimal.ZERO. The CustomerLifetimeValueService you’ll write later is the only writer; the field never gets edited from OrderService or from a controller. That single-writer property is what makes the listener pattern composable - if any other listener wrote to the same field, you’d have a race.

  • Order declares static mapping = { table 'orders' } because order is a reserved word in most SQL dialects (it’s the keyword in ORDER BY). Without the override the schema-generation either quotes the table name aggressively or fails outright depending on the database.

  • AuditLog deliberately does not use a foreign key to Order or Customer. It stores raw IDs and the snapshotted total. Audit history must survive the deletion of the rows it describes, and it must survive schema migrations that change the shape of Order.

  • Validation lives on the domains, not on the event class or the listeners. A listener receiving a malformed event is a programmer error; a listener receiving a well-formed event that fails domain validation is a domain change that needs a real migration.

Files touched in this chapter:

  • grails-app/domain/example/Customer.groovy

  • grails-app/domain/example/Order.groovy

  • grails-app/domain/example/AuditLog.groovy

5 The OrderPlacedEvent POGO

The event itself is a plain Groovy POGO. It carries the minimum data a listener needs to do its job - typically the IDs of the domain objects involved, plus any snapshotted value the listener should not look up again.

src/main/groovy/example/events/OrderPlacedEvent.groovy
package example.events

import groovy.transform.Immutable

/**
 * Domain event published by OrderService after an Order is persisted.
 *
 * No ApplicationEvent inheritance is required. Spring's
 * ApplicationEventPublisher.publishEvent(Object) overload wraps any
 * payload in a PayloadApplicationEvent transparently, so plain POGOs
 * are the recommended event type since Spring Framework 4.2.
 */
@Immutable
class OrderPlacedEvent {
    Long orderId
    Long customerId
    BigDecimal total
}

Three deliberate choices:

  • No extends ApplicationEvent. Since Spring Framework 4.2 (released 2015), ApplicationEventPublisher accepts any Object. If the published value isn’t already an ApplicationEvent, Spring wraps it in a PayloadApplicationEvent<T> transparently. Listener methods declare the payload type as their parameter, never the wrapper. See PayloadApplicationEvent javadoc.

  • @Immutable. The event is data, not a state machine. Listeners may run on different threads (the @Async listener you’ll write later runs on Spring’s task executor), and the listener-execution order is unspecified - mutating the event from one listener to communicate with another is a contract violation waiting to happen. Groovy’s @Immutable AST transformation generates a final-field, equals/hashCode-correct class for free.

  • IDs, not references. The event carries customerId, not a Customer instance. By the time the AFTER_COMMIT listeners run, the original Hibernate session is closed; passing a Customer instance would hand listeners a detached entity. Listeners that need the full row look it up by ID inside their own transaction.

The event class lives under src/main/groovy/example/events/ rather than grails-app/services/ because it isn’t a Grails artefact - Grails doesn’t auto-register it as a Spring bean and there’s nothing to inject. Plain Groovy under src/main/groovy/ is the right home for it.

Files touched in this chapter:

  • src/main/groovy/example/events/OrderPlacedEvent.groovy

6 Publishing Events From a Transactional Service

The publisher side is the boring side. OrderService.placeOrder(…​) is just a regular @Transactional Grails service that injects ApplicationEventPublisher and calls publishEvent once the persistence work is done.

grails-app/services/example/OrderService.groovy
package example

import example.events.OrderPlacedEvent
import grails.gorm.transactions.Transactional
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.ApplicationEventPublisher

@Transactional
class OrderService {

    @Autowired
    ApplicationEventPublisher applicationEventPublisher

    Order placeOrder(Long customerId, BigDecimal total) {
        Customer customer = Customer.get(customerId)
        if (!customer) {
            throw new IllegalArgumentException("Unknown customer: ${customerId}")
        }

        Order order = new Order(customer: customer, total: total).save(failOnError: true)

        // Fire-and-forget from the publisher's point of view. Spring buffers
        // the event in the synchronization manager and dispatches it to
        // @TransactionalEventListener beans only after this method's
        // transaction commits.
        applicationEventPublisher.publishEvent(
                new OrderPlacedEvent(order.id, customer.id, total))

        order
    }
}

What’s notable is what’s not in this class:

  • No Set<EventListener> field, no EventBus injection, no for (listener : listeners) listener.fire(…​) loop. The publisher doesn’t know who listens, how many listen, or whether anyone listens at all. Adding a fifth listener doesn’t touch this file.

  • No try/catch around publishEvent. A failure in a listener is a listener bug; it must not unwind the originating order placement. (For AFTER_COMMIT listeners the order has already durably committed by the time the listener runs - rolling back the listener is mostly meaningless anyway.)

  • No transaction management beyond @Transactional. The method runs in a single GORM transaction. The publishEvent call hands the event to Spring’s transaction synchronization manager; the listeners fire later, after this method’s transaction commits.

ApplicationEventPublisher is auto-injected because ApplicationContext implements it - every Spring application has exactly one publisher, available as a bean. @Autowired is explicit here but in Groovy services def applicationEventPublisher works too; the explicit declaration documents the type for readers.

Files touched in this chapter:

  • grails-app/services/example/OrderService.groovy

7 The First @TransactionalEventListener

The first listener bumps Customer.lifetimeValue after an order commits.

grails-app/services/example/CustomerLifetimeValueService.groovy
package example

import example.events.OrderPlacedEvent
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener

/**
 * Updates Customer.lifetimeValue after an order commits.
 *
 * An ordinary Grails service: because the class name ends in 'Service', Grails
 * auto-registers it as a Spring bean, and Spring scans its
 * @TransactionalEventListener method - no manual wiring. It carries no
 * @Transactional of its own, so Grails applies no transactional proxy.
 *
 * The AFTER_COMMIT callback runs after the publisher's transaction has already
 * committed, so this method opens its OWN transaction for the GORM write via
 * withNewTransaction { }. Do not reach for @Transactional(REQUIRES_NEW) to make
 * that declarative: Grails' @Transactional is an AST transform that relocates
 * the method body and would hide @TransactionalEventListener from Spring's
 * scanner, so the listener would never fire.
 */
class CustomerLifetimeValueService {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void onOrderPlaced(OrderPlacedEvent event) {
        Customer.withNewTransaction {
            Customer customer = Customer.get(event.customerId)
            if (customer) {
                customer.lifetimeValue = (customer.lifetimeValue ?: BigDecimal.ZERO) + event.total
                customer.save(flush: true, failOnError: true)
            }
        }
    }
}

Walk through it:

  • It is an ordinary Grails service. Because the class name ends in Service, Grails auto-registers it as a Spring bean with no configuration, and Spring’s EventListenerMethodProcessor scans its methods and registers the @TransactionalEventListener. The only extra wiring the pattern needs - a TransactionalEventListenerFactory bean - is covered in the next chapter.

  • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) binds the listener to the publisher’s transaction synchronization. When OrderService.placeOrder(…​) commits, Spring’s TransactionSynchronizationManager fires the afterCommit() callback that drains the buffered events into this method. If placeOrder(…​) rolls back, the buffered event is discarded and the method below is never called.

  • The listener does its GORM write inside Customer.withNewTransaction { …​ }. The publisher’s transaction has already committed by the time an AFTER_COMMIT listener runs - Spring is draining its after-commit callbacks before tearing the original transaction context down - so there is no transaction to piggy-back on and any GORM work needs a fresh one. withNewTransaction opens it programmatically. An @Transactional(propagation = REQUIRES_NEW) annotation would read more declaratively, but you cannot use it here: Grails' @Transactional is a compile-time AST transform that relocates the method body, which hides the @TransactionalEventListener annotation from Spring’s listener scanner - so the listener would never fire at all. withNewTransaction keeps the annotation where Spring can see it.

  • The method parameter type OrderPlacedEvent is how Spring picks which listener to call for which event. A second listener that accepts CustomerRegisteredEvent won’t see `OrderPlacedEvent`s, and vice versa. The event-to-listener wiring is implicit in the parameter type.

  • The if (customer) guard handles the (degenerate) case where the customer row has been deleted between the publisher commit and the listener firing. In a single-node deployment this is essentially impossible; in a multi-node deployment with concurrent admin tooling it can happen and the listener should noop rather than throw.

One thing the listener deliberately doesn’t do: re-publish another event. AFTER_COMMIT listeners can call applicationEventPublisher.publishEvent(…​) themselves, but doing so creates an event chain that lives outside the original publisher’s transaction. Keep listener responsibilities narrow; if a second event is logically part of the same business operation, publish it from the original service.

Files touched in this chapter:

  • grails-app/services/example/CustomerLifetimeValueService.groovy

8 The One Bean That Makes the Listeners Fire

The listeners are ordinary services, so there is nothing to register by hand. Grails wires every class under grails-app/services/ whose name ends in Service as a Spring bean automatically, and Spring’s EventListenerMethodProcessor scans those beans for @EventListener / @TransactionalEventListener methods. Giving the listeners Service names is what buys that: registration is convention, not configuration.

Two ways to make a listener a bean

The Service suffix is the lightest option, but it is not the only one. A class auto-registers only when it is a Grails artefact, and for grails-app/services/ that means the name must end in Service - a class called OrderAuditListener in that directory is silently ignored.

The alternative is to treat the listener as a plain Spring component: put it under src/main/groovy, annotate it @Component, and tell Spring which package to scan from your Application class.

import grails.boot.config.GrailsAutoConfiguration
import org.springframework.context.annotation.ComponentScan

@ComponentScan('example') (1)
class Application extends GrailsAutoConfiguration { /* ... */ }
1 @ComponentScan tells Spring to look for @Component, @Configuration, and related classes in the given package. If no package is given, the package of the annotated class is used as the root.

A default Grails Application scans only the grails-app/ artefact directories, so plain @Component classes under src/main/groovy stay invisible until you add @ComponentScan (or override packageNames()). If your app keeps any non-artefact components under src/main/groovy, it is worth adding @ComponentScan('your.package') to Application once, up front - then anything you drop there later is wired with no further ceremony. This guide keeps the listeners as Service classes because they need no annotation at all.

There is exactly one bean you do have to declare, and forgetting it fails silently.

@TransactionalEventListener needs a TransactionalEventListenerFactory bean

A plain @EventListener fires as soon as its class is a bean. @TransactionalEventListener does not: the transaction-phase-bound variant is only processed when a TransactionalEventListenerFactory bean exists in the context. A standard Spring Boot application contributes one automatically through @EnableTransactionManagement. Grails does not use @EnableTransactionManagement - it drives transactions through GORM’s own @Transactional AST transform - so that factory is never registered, and every @TransactionalEventListener is silently ignored until you declare it yourself:

grails-app/conf/spring/resources.groovy
beans = {
    // The listeners are ordinary services, so Grails auto-registers them - no
    // wiring needed there. This is the one bean the pattern does need: Grails
    // uses GORM's @Transactional, not Spring's @EnableTransactionManagement, so
    // it never registers a TransactionalEventListenerFactory, and without one
    // every @TransactionalEventListener silently no-ops. Declaring it here is
    // what makes the AFTER_COMMIT phases fire.
    transactionalEventListenerFactory(org.springframework.transaction.event.TransactionalEventListenerFactory)
}

That single line is the only wiring the pattern needs. The failure modes are worth committing to memory because they are both silent:

  • No TransactionalEventListenerFactory → plain @EventListener beans still fire, but every @TransactionalEventListener is ignored. This is the confusing one: the synchronous orchestration listener in the second half of this guide works, while the AFTER_COMMIT listeners appear dead.

  • Factory in place → everything fires.

A fast way to confirm the wiring during development is to drop a log.error('listener fired') as the first line of a listener and watch for it when a transaction commits.

Files touched in this chapter:

  • grails-app/conf/spring/resources.groovy

9 The Four TransactionPhase Values

TransactionPhase has four values. Pick the one that matches the side-effect’s tolerance for the originating transaction being undone.

Phase Fires Use for

BEFORE_COMMIT

Just before the database commit is issued, while the transaction is still open.

Side-effects that must be part of the same database commit as the originating change - typically derived data flushed into the same transaction. If the listener throws, the transaction rolls back.

AFTER_COMMIT (default)

After the database commit succeeds. The originating change is durable.

The 90% case: audit log writes, denormalised-field updates in other aggregates, outbound notifications, search-index refreshes, cache invalidations. Listener failures cannot undo the originating change - they must log and recover.

AFTER_ROLLBACK

After the database rollback completes. The originating change has been discarded.

Compensating side-effects: alerting on failed orders, releasing reserved inventory, decrementing speculative counters.

AFTER_COMPLETION

After the transaction completes, regardless of outcome. Aggregates both AFTER_COMMIT and AFTER_ROLLBACK.

Resource cleanup that must happen either way: closing a temporary file, releasing a distributed lock. Almost never the right choice for business logic, because the listener cannot easily tell which outcome occurred.

A few rules that catch everyone the first time:

  • No active transaction means no listener fires. If publishEvent(…​) is called outside a transactional context, the event is discarded. Override with fallbackExecution = true on the annotation if you really need the listener to run in the non-transactional case - but in a Grails service, @Transactional is almost always present and the fallback is rarely needed.

  • BEFORE_COMMIT listeners run inside the publisher’s transaction. Anything the listener does to GORM is part of the same database commit. This is occasionally exactly what you want (e.g. derived totals) but it’s a sharp edge - a listener exception unwinds the originating service call. For cross-domain orchestration that must share the transaction, a plain @EventListener (no phase) is usually the clearer tool, because it fires inline at the business step rather than deferred to commit time - the second half of this guide builds exactly that.

  • AFTER_COMMIT listeners run *after the publisher’s transaction commits.* The original transaction has already concluded by the time the listener fires - Spring is in the middle of draining its synchronization callbacks before tearing the transaction context down. The listener body is therefore not "in" a usable transaction; any database work must open a fresh one with withNewTransaction { …​ }. Reach for withNewTransaction, not @Transactional(REQUIRES_NEW): on a listener method that annotation’s AST transform would hide the listener from Spring (see The First @TransactionalEventListener).

  • Listener execution order is unspecified. Spring does not guarantee the order in which two AFTER_COMMIT listeners for the same event fire. If you need ordering, use @Order(…​) on the listener methods, but better: design the listeners so the order does not matter.

References:

Files touched in this chapter:

  • None

10 Fanning Out - A Second Listener

The whole point of the pattern is that fanning out is cheap: a new side-effect is a new class, not an edit to OrderService. Here’s a second listener that writes an AuditLog row.

grails-app/services/example/AuditService.groovy
package example

import example.events.OrderPlacedEvent
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener

/**
 * Persists an AuditLog row for every committed order.
 *
 * A separate service - not a method on OrderService - keeps the "place an
 * order" concern decoupled from the "record audit history" concern. Adding
 * another committed-only side-effect later means a new service, not an edit to
 * OrderService.
 *
 * Like CustomerLifetimeValueService it is auto-registered by Grails (its name
 * ends in 'Service') and does its GORM write inside withNewTransaction { }
 * because the AFTER_COMMIT callback fires after the publisher's transaction
 * has committed.
 */
class AuditService {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void onOrderPlaced(OrderPlacedEvent event) {
        AuditLog.withNewTransaction {
            new AuditLog(
                    eventType:  'ORDER_PLACED',
                    orderId:    event.orderId,
                    customerId: event.customerId,
                    orderTotal: event.total,
                    occurredAt: new Date()
            ).save(failOnError: true)
        }
    }
}

Notice that this file is independent of CustomerLifetimeValueService. They share nothing - no inheritance, no interface. Adding a third side-effect tomorrow - a webhook, a search-index refresh, a metrics counter - means writing a WebhookService class and nothing else: Grails auto-registers it, and Spring wires both against the OrderPlacedEvent type and calls both (in unspecified order) when an order commits; the publisher never changes.

AuditLog snapshots the data it needs at event-time: orderTotal is copied from the event, not looked up by querying the (now-detached) Order. That decoupling has a few benefits:

  • The listener doesn’t need a Order.get(…​) round-trip. One INSERT per audit row.

  • The audit history is immune to later edits of Order.total (which shouldn’t happen, but might).

  • If Order is hard-deleted later, the audit row still tells the full story.

A common temptation is to add a @Order(0) annotation here to guarantee the audit row is written before the customer lifetime value updates. Resist it unless ordering is part of the actual business contract. Two AFTER_COMMIT listeners that need a specific ordering are usually one chained business operation in disguise, and the right refactor is to make the audit listener publish a second event the lifetime listener listens for - or to merge them into a single listener that does both writes in one transaction.

Files touched in this chapter:

  • grails-app/services/example/AuditService.groovy

11 Off-Thread Dispatch With @Async

The third listener stands in for "send a confirmation email" - exactly the kind of side-effect you don’t want to do on the caller’s thread. Composing @Async with @TransactionalEventListener hands the listener body to Spring’s task executor after the commit lands.

grails-app/services/example/NotificationService.groovy
package example

import example.events.OrderPlacedEvent
import org.springframework.scheduling.annotation.Async
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener

/**
 * Off-thread side-effect: stand-in for sending a confirmation email.
 *
 * An ordinary Grails service, auto-registered by name - which also means Grails
 * injects a `log` (SLF4J) property for free, so the class declares none of its
 * own. @Async dispatches the listener body to Spring's task executor instead of
 * running it on the caller's thread. The transaction-phase binding is still
 * synchronous - Spring waits for AFTER_COMMIT, then hands the event to the
 * executor. The body therefore has NO inherited transaction; if it needs the
 * database it must open its own via withNewTransaction { }.
 *
 * Exceptions thrown from an @Async listener are NOT propagated to the
 * publisher - see Spring's AsyncUncaughtExceptionHandler.
 *
 * @EnableAsync must be present on Application for @Async to take effect;
 * without it the annotation is silently a no-op.
 */
class NotificationService {

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void onOrderPlaced(OrderPlacedEvent event) {
        log.info('Off-thread: sending confirmation email for order {} (total {})',
                event.orderId, event.total)
        // Real implementation would call emailService.send(...)
    }
}

Two pieces of wiring this listener needs:

@EnableAsync somewhere in your Spring configuration. The typical home is the Application class:

grails-app/init/example/Application.groovy
package example

import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import grails.plugins.metadata.PluginSource
import org.springframework.scheduling.annotation.EnableAsync

@PluginSource
@EnableAsync
class Application extends GrailsAutoConfiguration {
    static void main(String[] args) {
        GrailsApp.run(Application, args)
    }
}

Without @EnableAsync, the @Async annotation is silently a no-op and the listener runs on the publisher’s thread.

The composition order with @TransactionalEventListener is exact:

  1. The publisher’s transaction commits.

  2. Spring’s TransactionSynchronizationManager fires the AFTER_COMMIT callback.

  3. The callback dispatches the event to listener method references.

  4. Because this listener method is also @Async, the call is handed to the taskExecutor bean instead of running on the synchronization thread.

  5. The executor thread invokes onOrderPlaced(…​) with no inherited transaction context.

The "no inherited transaction context" point is important. If the async listener needs database access, it must open its own transaction with withNewTransaction { }, exactly like the CustomerLifetimeValueService did - and for the same reason it can’t be declared with @Transactional: that annotation’s AST transform would hide the listener method from Spring. Forgetting this and writing GORM code directly in an @Async listener body produces an enigmatic org.hibernate.HibernateException: No Session found for current thread from a stack frame that has nothing else to do with persistence.

A few footguns:

  • Exception handling is detached. Exceptions thrown from an async listener don’t reach the publisher. They land in Spring’s AsyncUncaughtExceptionHandler - the default implementation, SimpleAsyncUncaughtExceptionHandler, logs them at ERROR level. For listeners that drive critical infrastructure, install an explicit handler so failures fan out to your alerting pipeline instead of getting buried in the log.

  • Return values are detached. Don’t return a value from an async listener expecting Spring to publish it as a follow-up event - that mechanism (@EventListener returning a value to be auto-published) does not compose with async dispatch. Inject ApplicationEventPublisher and call publishEvent(…​) explicitly if you need a follow-up event.

  • Thread-pool tuning matters. The default taskExecutor is sized for development. For production, declare a ThreadPoolTaskExecutor bean with a bounded queue and a sensible rejection policy - otherwise a burst of orders can pile up an unbounded queue of pending notification work.

References:

Files touched in this chapter:

  • grails-app/services/example/NotificationService.groovy

  • grails-app/init/example/Application.groovy (add @EnableAsync)

12 vs. GORM Lifecycle Callbacks and @Subscriber

Grails has three mechanisms that loosely resemble "do something when a domain object changes". They look interchangeable in tutorials and are not interchangeable in practice. Here’s the contrast that the rest of this guide implicitly assumes.

Mechanism Fires when Lives where Use for

Domain-class lifecycle callbacks
(beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeDelete, afterDelete)

During the Hibernate flush, inside the surrounding transaction. Bound to a specific domain class.

As methods on the domain class itself. The domain class is not a Spring bean; injecting other Spring beans is awkward (use Holders.applicationContext.getBean(…​) or the deprecated defineBeans indirection).

Trivial single-domain hygiene that must be in the same database transaction. Setting a createdBy field, normalising a string, computing a derived column. Never for cross-domain or external-system work.

Grails events (@Listener, @Subscriber, EventBus)

At Hibernate-event level, synchronously (@Listener) or via the EventBus (@Subscriber). Still bound to the persistence flush, not to the Spring transaction commit.

Beans annotated with the Grails-specific annotations. Publisher uses EventPublisher or the eventBus bean.

Grails-internal events you didn’t define (think GORM-level audit, soft-delete plugin hooks). The vocabulary is GORM-flavoured: PreInsertEvent, PostUpdateEvent, PreDeleteEvent.

Spring application events
(ApplicationEventPublisher, @EventListener, @TransactionalEventListener)

@EventListener - the moment publishEvent is called.
@TransactionalEventListener - bound to the Spring transaction commit/rollback, not the Hibernate flush.

Any Spring bean. Publisher is ApplicationEventPublisher, available everywhere.

Cross-domain business logic, especially anything that touches an external system (email, search index, webhook, cache) and must only run on durable commit. This guide’s pattern.

Three rules that come out of the table:

  • If the side-effect is single-domain and must be in the same transaction (a derived column, a createdBy audit field), use a lifecycle callback. Don’t reach for an event system.

  • If the side-effect crosses domains and should only happen on durable commit (an audit row, an email, a webhook), use @TransactionalEventListener(AFTER_COMMIT). This is the gap GORM lifecycle callbacks don’t fill.

  • If you’re reading existing code that uses @Subscriber / @Listener for cross-domain business logic, treat it as a smell. Those annotations were originally designed for Grails plugin developers extending GORM, not for application-level cross-domain logic. The Spring-transactional pattern in this guide is the cleaner replacement.

A common claim worth being precise about: "transactional Spring events just work with GORM." That’s true, and the reason is mechanical, not magical. GORM ships HibernateTransactionManager as the Spring PlatformTransactionManager bean. @TransactionalEventListener registers a transaction-synchronization callback on the current PlatformTransactionManager. Since that’s the same HibernateTransactionManager GORM is committing through, the AFTER_COMMIT callback fires exactly when the GORM transaction commits to the database. There is no Grails-specific bridge.

References:

Files touched in this chapter:

  • None

13 Proving the Contract With an Integration Spec

The whole point of AFTER_COMMIT is that listeners do not fire when the publisher’s transaction rolls back. That property is invisible in unit tests - it only shows up when a real Spring PlatformTransactionManager actually commits or rolls back a real database transaction. The right test layer is a Grails @Integration Spock spec.

src/integration-test/groovy/example/OrderServiceIntegrationSpec.groovy
package example

import grails.testing.mixin.integration.Integration
import spock.lang.Specification

/**
 * Integration spec that proves the two non-negotiable claims:
 *
 *   1. AFTER_COMMIT listeners fire when (and only when) the outer
 *      transaction commits.
 *   2. AFTER_COMMIT listeners are silently skipped when the outer
 *      transaction rolls back - no phantom audit rows, no phantom
 *      emails for orders that never durably existed.
 *
 * Each test method opens its own committed transaction via
 * Customer.withNewTransaction { ... } / Order.withNewTransaction { ... }
 * because @Rollback would also prevent the AFTER_COMMIT listeners from
 * firing, which would conflate "the listener never ran because rollback"
 * with "the listener never ran because @Rollback".
 */
@Integration
class OrderServiceIntegrationSpec extends Specification {

    OrderService orderService

    void "AFTER_COMMIT listeners run only after the outer transaction commits"() {
        given:
        Customer customer = createCommitted('Eve Lin', 'eve@example.com')
        long auditBefore = AuditLog.withNewTransaction { AuditLog.count() }
        BigDecimal lvBefore = Customer.withNewTransaction { Customer.get(customer.id).lifetimeValue }

        when: 'placeOrder runs inside a transaction that commits'
        Order placed = null
        Order.withNewTransaction {
            placed = orderService.placeOrder(customer.id, new BigDecimal('19.95'))
        }

        then: 'Both AFTER_COMMIT listeners fired'
        AuditLog.withNewTransaction { AuditLog.count() } == auditBefore + 1
        Customer.withNewTransaction { Customer.get(customer.id).lifetimeValue } == lvBefore + new BigDecimal('19.95')

        cleanup:
        deleteCommitted(placed, customer)
    }

    void "AFTER_COMMIT listeners are skipped when the outer transaction rolls back"() {
        given:
        Customer customer = createCommitted('Fay Park', 'fay@example.com')
        long auditBefore = AuditLog.withNewTransaction { AuditLog.count() }
        BigDecimal lvBefore = Customer.withNewTransaction { Customer.get(customer.id).lifetimeValue }

        when: 'placeOrder runs inside a transaction that explicitly rolls back'
        Order.withNewTransaction { status ->
            orderService.placeOrder(customer.id, new BigDecimal('999.00'))
            status.setRollbackOnly()
        }

        then: 'Neither AFTER_COMMIT listener fired'
        AuditLog.withNewTransaction { AuditLog.count() } == auditBefore
        Customer.withNewTransaction { Customer.get(customer.id).lifetimeValue } == lvBefore

        cleanup:
        deleteCommitted(null, customer)
    }

    void "placing two orders accumulates lifetime value and writes one audit row each"() {
        given:
        Customer customer = createCommitted('Gus Hall', 'gus@example.com')
        long auditBefore = AuditLog.withNewTransaction { AuditLog.count() }

        when: 'two orders are placed in separate committed transactions'
        Order first = null
        Order second = null
        Order.withNewTransaction { first = orderService.placeOrder(customer.id, new BigDecimal('10.00')) }
        Order.withNewTransaction { second = orderService.placeOrder(customer.id, new BigDecimal('5.50')) }

        then: 'both AFTER_COMMIT fan-outs ran: two audit rows and the summed lifetime value'
        AuditLog.withNewTransaction { AuditLog.count() } == auditBefore + 2
        Customer.withNewTransaction { Customer.get(customer.id).lifetimeValue } == new BigDecimal('15.50')

        cleanup:
        Order.withNewTransaction {
            AuditLog.where { orderId == first.id || orderId == second.id }.deleteAll()
            Order.get(first.id)?.delete()
            Order.get(second.id)?.delete()
            Customer.get(customer.id)?.delete(flush: true)
        }
    }

    private Customer createCommitted(String name, String email) {
        Customer created = null
        Customer.withNewTransaction {
            created = new Customer(name: name, email: email).save(flush: true, failOnError: true)
        }
        created
    }

    private void deleteCommitted(Order placed, Customer customer) {
        Order.withNewTransaction {
            if (placed) {
                AuditLog.where { orderId == placed.id }.deleteAll()
                Order.get(placed.id)?.delete(flush: true)
            }
            Customer.get(customer.id)?.delete(flush: true)
        }
    }
}

A few details that catch people the first time:

  • Why the spec is not annotated @Rollback. The default integration-test behaviour in Grails wraps each test method in a transaction that’s rolled back after the method returns. If you let that wrapper roll back the outer transaction, the AFTER_COMMIT listeners inside placeOrder(…​) never fire - and the test cannot distinguish "the listener didn’t run because of rollback semantics (good)" from "the listener didn’t run because the test wrapper rolled back (irrelevant)". The spec therefore opens its own committed transaction with Order.withNewTransaction { …​ } and cleans up explicitly.

  • Why setRollbackOnly() instead of throwing. Throwing an exception from inside withNewTransaction { …​ } would propagate out of the test method and fail the spec. status.setRollbackOnly() tells Spring to roll back when the block exits normally, which is exactly the "user-initiated rollback" scenario the test wants to model.

  • Why the cleanup: block uses withNewTransaction again. The test ran outside the integration-test transaction wrapper, so there is no automatic rollback to lean on. The cleanup must explicitly delete the rows the test created, in their own transaction, in the right order (AuditLog rows first because they reference orderId, then Order, then Customer).

  • Why the async NotificationService is not asserted on. It logs only - there is nothing in the database to assert against. Testing that an @Async listener actually ran requires either an awaitility-style poll or replacing the executor with a synchronous one in the test profile. The two non-async listeners are sufficient to demonstrate the AFTER_COMMIT contract.

Run the spec:

./gradlew integrationTest --tests example.OrderServiceIntegrationSpec

Both methods should pass. The first proves that AFTER_COMMIT listeners fire when the publisher commits; the second proves they’re silently skipped when it rolls back. That’s the entire contract the pattern guarantees.

Files touched in this chapter:

  • src/integration-test/groovy/example/OrderServiceIntegrationSpec.groovy

14 When the Side-Effect IS the Transaction

Everything up to this point has been about side-effects that run after the commit. AFTER_COMMIT is the right phase when the side-effect must not be able to undo the originating change: an audit row, a confirmation email, a search-index refresh. The originating order has durably committed; the listener reacts to a fact.

There is a second, equally common shape that AFTER_COMMIT is the wrong tool for: cross-domain changes that are part of the same unit of work. Not "react to a committed fact" but "this whole thing happens, or none of it does."

Take a field-service workflow with three domains:

  • CustomerRequest - what the customer asked for.

  • WorkOrder - a unit of work under a request.

  • WorkOrderAssignee - the join that puts an employee on a work order.

The rule the business cares about: when an employee is assigned to a work order, the work order becomes PLANNED, and because the work order is now planned, the originating customer request becomes IN_PROGRESS. Three writes - the assignment, the work-order status, the request status - that must be atomic. If the request can’t legally move to IN_PROGRESS (it was already cancelled, say), the assignment and the work-order status change must roll back too. You do not want a PLANNED work order hanging off a CANCELLED request because two of the three writes landed and the third didn’t.

AFTER_COMMIT cannot express this. By the time an AFTER_COMMIT listener runs, the assignment has already committed; the fresh withNewTransaction it would open is a separate unit of work, and a failure there leaves the assignment durably persisted with no way to walk it back. The same property that makes AFTER_COMMIT correct for emails - "the original is already safe" - makes it wrong for orchestration.

The fix is the listener you have not used yet: a plain @EventListener, with no transaction phase, consuming an event that the publisher fires from inside its @Transactional method. Spring’s default event multicaster is synchronous, so the listener runs inline - on the publisher’s thread, inside the publisher’s transaction, sharing the publisher’s Hibernate session - before publishEvent(…​) returns. Reads see in-flight, uncommitted state. Writes join the same commit. An exception unwinds the whole operation.

This is the mirror image of the first half of the guide, and it reuses the same machinery (ApplicationEventPublisher, a POGO event, a listener service in grails-app/services/). The only thing that changes is the listener annotation - and that one change moves the listener from "after the transaction" to "inside the transaction."

@TransactionalEventListener(AFTER_COMMIT) plain @EventListener

Fires after the publisher’s commit

Fires inline, the instant publishEvent(…​) is called

Runs in a new transaction / session (opened with withNewTransaction)

Runs in the publisher’s transaction / session

Reads committed data only

Reads in-flight, uncommitted data

Listener failure cannot undo the original

Listener failure rolls the whole operation back

Audit, email, webhooks, projections

Atomic cross-domain orchestration

The rest of this part builds the work-order example end to end and proves the atomicity with an integration spec.

Files touched in this chapter:

  • None

15 The CustomerRequest, WorkOrder, and WorkOrderAssignee Domain Model

Three domains and two status enums model the workflow. A CustomerRequest owns one or more WorkOrder`s; each `WorkOrder owns its `WorkOrderAssignee`s. The status fields are the state the orchestration moves.

Start with the enums. Keeping status in a typed enum rather than a free String means the orchestration logic reads like the business rule it implements, and an illegal value is a compile error rather than a runtime surprise.

src/main/groovy/example/CustomerRequestStatus.groovy
package example

/**
 * Lifecycle of a customer request. SUBMITTED is the entry state;
 * COMPLETED and CANCELLED are terminal. The only automated transition
 * this guide drives is SUBMITTED -> IN_PROGRESS, performed by the
 * synchronous orchestration listener when work is planned.
 */
enum CustomerRequestStatus {
    SUBMITTED,
    IN_PROGRESS,
    COMPLETED,
    CANCELLED

    boolean isTerminal() {
        this == COMPLETED || this == CANCELLED
    }
}
src/main/groovy/example/WorkOrderStatus.groovy
package example

/**
 * Lifecycle of a work order. A work order starts OPEN with no assignee.
 * Assigning an employee moves it to PLANNED; finishing the job moves it
 * to COMPLETED. This guide drives the OPEN -> PLANNED transition.
 */
enum WorkOrderStatus {
    OPEN,
    PLANNED,
    COMPLETED
}

The isTerminal() helper on CustomerRequestStatus is the one piece of business logic that lives on the enum: a request that is COMPLETED or CANCELLED is closed and cannot be dragged back into IN_PROGRESS. The orchestration listener consults it before advancing a request, and it is exactly the guard the rollback test will trip.

Now the domains.

grails-app/domain/example/CustomerRequest.groovy
package example

import grails.persistence.Entity

/**
 * A request raised by a customer. One CustomerRequest spawns one or more
 * WorkOrders. Its status is advanced by the orchestration listener as the
 * work underneath it progresses - never edited directly from a controller.
 */
@Entity
class CustomerRequest {

    String summary
    CustomerRequestStatus status = CustomerRequestStatus.SUBMITTED

    static constraints = {
        summary blank: false, maxSize: 255
        status  nullable: false
    }

    String toString() { summary }
}
grails-app/domain/example/WorkOrder.groovy
package example

import grails.persistence.Entity

/**
 * A unit of work belonging to a CustomerRequest. Starts OPEN; moves to
 * PLANNED the moment an employee is assigned. The status change is driven
 * by the synchronous orchestration listener, inside the same transaction
 * that created the assignment.
 */
@Entity
class WorkOrder {

    CustomerRequest customerRequest
    String description
    WorkOrderStatus status = WorkOrderStatus.OPEN

    static belongsTo = [customerRequest: CustomerRequest]

    static constraints = {
        description blank: false, maxSize: 255
        status      nullable: false
    }

    static mapping = {
        // 'order' is reserved in most SQL dialects; the prefixed table name
        // sidesteps the same family of keyword collisions for work orders.
        table 'work_orders'
    }
}
grails-app/domain/example/WorkOrderAssignee.groovy
package example

import grails.persistence.Entity

/**
 * Joins an employee to a WorkOrder. Creating one of these is the business
 * event that kicks off the whole orchestration: the assignment, the work
 * order's status change, and the customer request's status change all
 * commit together or not at all.
 */
@Entity
class WorkOrderAssignee {

    WorkOrder workOrder
    String employeeName
    Date dateCreated

    static belongsTo = [workOrder: WorkOrder]

    static constraints = {
        employeeName blank: false, maxSize: 255
    }
}

A few choices worth pointing at:

  • The belongsTo chain is deliberate. WorkOrder belongsTo CustomerRequest and WorkOrderAssignee belongsTo WorkOrder give the listener a clean navigation path: from the assignee’s work order, workOrder.customerRequest reaches the request without a separate query. Because the listener runs in the publisher’s session (next chapter), that navigation hits the first-level cache, not the database.

  • Status fields default to the entry state and are never edited from a controller. CustomerRequest.status starts SUBMITTED; WorkOrder.status starts OPEN. The orchestration listener is the only writer of those transitions. Single-writer status, like the single-writer Customer.lifetimeValue in the first half of the guide, is what keeps the pattern composable.

  • WorkOrder maps to work_orders. order is a reserved word in most SQL dialects and work order collides with the same keyword family; the explicit table name sidesteps it, exactly as Order did earlier.

  • Validation lives on the domains. The orchestration uses save(failOnError: true), so a domain constraint violation becomes a thrown exception - and, as the next chapter shows, that exception is what makes the whole transaction roll back.

Files touched in this chapter:

  • src/main/groovy/example/CustomerRequestStatus.groovy

  • src/main/groovy/example/WorkOrderStatus.groovy

  • grails-app/domain/example/CustomerRequest.groovy

  • grails-app/domain/example/WorkOrder.groovy

  • grails-app/domain/example/WorkOrderAssignee.groovy

16 Synchronous Orchestration With a Plain @EventListener

The event is another plain POGO, identical in spirit to OrderPlacedEvent: immutable, carrying IDs rather than entity references.

src/main/groovy/example/events/EmployeeAssignedEvent.groovy
package example.events

import groovy.transform.Immutable

/**
 * Published by WorkOrderAssignmentService the moment a WorkOrderAssignee is
 * created. Unlike OrderPlacedEvent (consumed AFTER_COMMIT), this event is
 * consumed SYNCHRONOUSLY, inside the publisher's transaction, by a plain
 * @EventListener. It still carries IDs rather than entity references, but
 * the listener that handles it runs in the same Hibernate session, so a
 * WorkOrder.get(workOrderId) returns the live, in-flight instance.
 */
@Immutable
class EmployeeAssignedEvent {
    Long workOrderId
    Long assigneeId
    String employeeName
}

The publisher is also familiar - a @Transactional service that persists, then publishes.

grails-app/services/example/WorkOrderAssignmentService.groovy
package example

import example.events.EmployeeAssignedEvent
import grails.gorm.transactions.Transactional
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.ApplicationEventPublisher

/**
 * Assigns an employee to a work order. The persistence work and the event
 * publish both happen inside one @Transactional method.
 *
 * Contrast with OrderService: there the listeners run AFTER this method
 * commits, in their own transactions. Here the event is consumed by a plain
 * @EventListener, which Spring dispatches SYNCHRONOUSLY at the publishEvent
 * call - on this thread, inside this transaction, before this method returns.
 * So the assignee insert, plus every change the listener makes, are one
 * atomic unit: they all commit together or all roll back together.
 */
@Transactional
class WorkOrderAssignmentService {

    @Autowired
    ApplicationEventPublisher applicationEventPublisher

    WorkOrderAssignee assignEmployee(Long workOrderId, String employeeName) {
        WorkOrder workOrder = WorkOrder.get(workOrderId)
        if (!workOrder) {
            throw new IllegalArgumentException("Unknown work order: ${workOrderId}")
        }

        WorkOrderAssignee assignee = new WorkOrderAssignee(
                workOrder: workOrder, employeeName: employeeName)
                .save(failOnError: true)

        // Synchronous dispatch: the @EventListener for this event runs inline,
        // right here, inside this transaction and Hibernate session. If it
        // throws, the exception propagates back through this call and rolls
        // the whole method - including the assignee insert above - back.
        applicationEventPublisher.publishEvent(
                new EmployeeAssignedEvent(workOrder.id, assignee.id, employeeName))

        assignee
    }
}

This file is byte-for-byte the same shape as OrderService: inject ApplicationEventPublisher, do the persistence, call publishEvent(…​), return. The publisher does not know or care who listens. The difference is entirely on the listener side.

grails-app/services/example/WorkOrderPlanningService.groovy
package example

import example.events.EmployeeAssignedEvent
import org.springframework.context.event.EventListener

/**
 * Synchronous, in-transaction orchestration.
 *
 * A plain @EventListener (NOT @TransactionalEventListener) runs inline on the
 * publisher's thread, inside the publisher's transaction and Hibernate session:
 * reads see in-flight state, writes join the same commit, and a thrown
 * exception rolls the whole operation back.
 *
 * It is an ordinary Grails service (auto-registered by name) but deliberately
 * carries NO @Transactional: Grails' @Transactional is a compile-time AST
 * transform that relocates the method body and would hide @EventListener from
 * Spring's scanner, so the listener would never register. It needs no
 * transaction of its own anyway - it runs inside the one the publisher started.
 */
class WorkOrderPlanningService {

    @EventListener
    void onEmployeeAssigned(EmployeeAssignedEvent event) {
        WorkOrder workOrder = WorkOrder.get(event.workOrderId)

        // An assigned work order is ready to be planned.
        if (workOrder.status == WorkOrderStatus.OPEN) {
            workOrder.status = WorkOrderStatus.PLANNED
            workOrder.save(failOnError: true)
        }

        // Planning work means the originating request is now in progress.
        // This read sees the WorkOrder transition made one statement ago,
        // because both run in the same transaction.
        CustomerRequest request = workOrder.customerRequest
        if (request.status.terminal) {
            throw new IllegalStateException(
                    "Cannot start work on request ${request.id}: it is ${request.status}")
        }
        if (request.status == CustomerRequestStatus.SUBMITTED) {
            request.status = CustomerRequestStatus.IN_PROGRESS
            request.save(failOnError: true)
        }
    }
}

The single annotation that changes everything is @EventListener instead of @TransactionalEventListener(AFTER_COMMIT). Walk through what that buys you:

  • It fires synchronously, inline, now. Spring’s default ApplicationEventMulticaster is SimpleApplicationEventMulticaster, which invokes listeners directly on the calling thread. The call stack is assignEmployee(…​)publishEvent(…​)onEmployeeAssigned(…​) → back. No commit has happened yet; the publisher’s @Transactional method has not even returned.

  • It runs in the publisher’s transaction and session. The listener service itself carries no @Transactional - it runs inline inside the publisher’s @Transactional (default REQUIRED) method, so its GORM work simply joins that active transaction rather than starting a new one. That shared transaction is the entire point - it is why the listener’s writes commit with the assignment and roll back with it.

  • Its reads see in-flight state. WorkOrder.get(event.workOrderId) returns the managed instance from the current session, with the WorkOrderAssignee that was inserted moments ago already attached. When the listener flips the work order to PLANNED and then reads workOrder.customerRequest, it is reading uncommitted, transaction-local data - exactly what you cannot do from an AFTER_COMMIT listener.

  • Its failures roll the operation back. The isTerminal() guard throws IllegalStateException for a closed request. That exception propagates out through publishEvent(…​), out of assignEmployee(…​), and Grails' transactional advice marks the transaction for rollback. The assignee insert, the work-order status change, and any partial request change are all discarded together.

A note on save(failOnError: true). Strictly, dirty-checking would flush the mutated WorkOrder and CustomerRequest at commit without an explicit save. Calling save(failOnError: true) is deliberate anyway: it runs validation now, at the business step, and turns a constraint violation into a thrown exception that participates in the same rollback - rather than a silent null return or a deferred flush exception surfacing at commit time from a confusing stack frame.

This pattern depends on synchronous delivery. Do not put @Async on this listener: an async listener runs on a different thread with no inherited transaction, its writes would land in (or fail to find) a separate transaction, and - critically - an exception it throws would go to Spring’s AsyncUncaughtExceptionHandler instead of rolling the publisher back. Synchronous in-transaction orchestration and @Async are mutually exclusive by design.

Single listener vs. a chain of events. The example does both status transitions in one listener. You can model the cascade as a chain - have this listener publish a WorkOrderPlannedEvent that a second synchronous listener consumes to advance the request - and because every listener in the chain is synchronous, the whole chain runs depth-first inside the one transaction and rolls back as a unit. Reach for chaining only when the intermediate event (WorkOrderPlannedEvent) is independently meaningful to other consumers. Otherwise a single listener is clearer and avoids the chain’s sharp edges: listener ordering, and the fact that Spring does no cycle detection, so a listener that (directly or transitively) re-publishes its own trigger will recurse until the stack overflows.

Why not @TransactionalEventListener(BEFORE_COMMIT)? It also runs in the publisher’s transaction and would also roll back on failure, so it can do atomic work. But it fires at a different time - after the service method body has fully returned, while the commit is being prepared - not at the business step that published the event. That makes it a transaction-lifecycle hook (good for last-moment validation or derived totals computed from the final state of the transaction), not the natural home for causal, mid-method orchestration that wants to read and react to in-flight state immediately. Lead with the plain @EventListener; reach for BEFORE_COMMIT only when you specifically need the deferred, just-before-commit timing.

References:

Files touched in this chapter:

  • src/main/groovy/example/events/EmployeeAssignedEvent.groovy

  • grails-app/services/example/WorkOrderAssignmentService.groovy

  • grails-app/services/example/WorkOrderPlanningService.groovy

17 Proving Atomic Rollback With an Integration Spec

The whole promise of this pattern is atomicity: on a mid-orchestration failure, nothing persists. Like the AFTER_COMMIT contract, that property is invisible to unit tests - it only shows up against a real PlatformTransactionManager committing or rolling back a real database transaction. A Grails @Integration Spock spec is again the right layer.

src/integration-test/groovy/example/WorkOrderAssignmentIntegrationSpec.groovy
package example

import grails.testing.mixin.integration.Integration
import spock.lang.Specification

/**
 * Integration spec that proves the same-transaction orchestration is atomic:
 *
 *   1. On success, the assignment, the WorkOrder status, and the
 *      CustomerRequest status all commit together.
 *   2. On a failure raised mid-orchestration (the listener rejecting an
 *      illegal request transition), EVERY change rolls back - no assignee
 *      row, the WorkOrder is still OPEN, the CustomerRequest is untouched.
 *
 * As in OrderServiceIntegrationSpec, each method drives its own committed
 * transactions via withNewTransaction { ... } and asserts in fresh
 * transactions, so the assertions read committed database state rather than
 * mutated first-level-cache instances.
 */
@Integration
class WorkOrderAssignmentIntegrationSpec extends Specification {

    WorkOrderAssignmentService workOrderAssignmentService

    void "assigning an employee atomically plans the work order and advances the request"() {
        given:
        Long[] ids = createScenario(CustomerRequestStatus.SUBMITTED)
        Long requestId = ids[0]
        Long workOrderId = ids[1]

        when: 'an employee is assigned inside a transaction that commits'
        WorkOrderAssignee assignee = null
        WorkOrder.withNewTransaction {
            assignee = workOrderAssignmentService.assignEmployee(workOrderId, 'Dana Reed')
        }

        then: 'all three changes committed as one unit'
        assignee.id != null
        WorkOrder.withNewTransaction { WorkOrder.get(workOrderId).status } == WorkOrderStatus.PLANNED
        CustomerRequest.withNewTransaction { CustomerRequest.get(requestId).status } == CustomerRequestStatus.IN_PROGRESS

        cleanup:
        deleteScenario(workOrderId, requestId)
    }

    void "a failure mid-orchestration rolls every change back"() {
        given: 'a work order whose customer request is already closed'
        Long[] ids = createScenario(CustomerRequestStatus.COMPLETED)
        Long requestId = ids[0]
        Long workOrderId = ids[1]
        long assigneesBefore = countAssignees(workOrderId)

        when: 'assigning an employee triggers an illegal request transition'
        WorkOrder.withNewTransaction {
            workOrderAssignmentService.assignEmployee(workOrderId, 'Dana Reed')
        }

        then: 'the orchestration threw'
        thrown(IllegalStateException)

        and: 'nothing persisted - no assignee, both statuses unchanged'
        countAssignees(workOrderId) == assigneesBefore
        WorkOrder.withNewTransaction { WorkOrder.get(workOrderId).status } == WorkOrderStatus.OPEN
        CustomerRequest.withNewTransaction { CustomerRequest.get(requestId).status } == CustomerRequestStatus.COMPLETED

        cleanup:
        deleteScenario(workOrderId, requestId)
    }

    void "assigning a second employee to an already-planned work order does not re-run the cascade"() {
        given: 'a work order already planned by a first assignment'
        Long[] ids = createScenario(CustomerRequestStatus.SUBMITTED)
        Long requestId = ids[0]
        Long workOrderId = ids[1]
        WorkOrder.withNewTransaction {
            workOrderAssignmentService.assignEmployee(workOrderId, 'Dana Reed')
        }

        when: 'a second employee is assigned'
        WorkOrder.withNewTransaction {
            workOrderAssignmentService.assignEmployee(workOrderId, 'Sam Ortiz')
        }

        then: 'both assignees exist, but the OPEN/SUBMITTED guards make the status changes a no-op'
        countAssignees(workOrderId) == 2
        WorkOrder.withNewTransaction { WorkOrder.get(workOrderId).status } == WorkOrderStatus.PLANNED
        CustomerRequest.withNewTransaction { CustomerRequest.get(requestId).status } == CustomerRequestStatus.IN_PROGRESS

        cleanup:
        deleteScenario(workOrderId, requestId)
    }

    void "a cancelled request also blocks the assignment and rolls everything back"() {
        given:
        Long[] ids = createScenario(CustomerRequestStatus.CANCELLED)
        Long requestId = ids[0]
        Long workOrderId = ids[1]
        long assigneesBefore = countAssignees(workOrderId)

        when:
        WorkOrder.withNewTransaction {
            workOrderAssignmentService.assignEmployee(workOrderId, 'Dana Reed')
        }

        then:
        thrown(IllegalStateException)

        and:
        countAssignees(workOrderId) == assigneesBefore
        WorkOrder.withNewTransaction { WorkOrder.get(workOrderId).status } == WorkOrderStatus.OPEN
        CustomerRequest.withNewTransaction { CustomerRequest.get(requestId).status } == CustomerRequestStatus.CANCELLED

        cleanup:
        deleteScenario(workOrderId, requestId)
    }

    private Long[] createScenario(CustomerRequestStatus requestStatus) {
        Long requestId = null
        Long workOrderId = null
        CustomerRequest.withNewTransaction {
            CustomerRequest request = new CustomerRequest(
                    summary: 'Replace the rooftop HVAC unit', status: requestStatus)
                    .save(flush: true, failOnError: true)
            WorkOrder workOrder = new WorkOrder(
                    customerRequest: request, description: 'Site survey and install')
                    .save(flush: true, failOnError: true)
            requestId = request.id
            workOrderId = workOrder.id
        }
        [requestId, workOrderId] as Long[]
    }

    private long countAssignees(Long workOrderId) {
        WorkOrderAssignee.withNewTransaction {
            WorkOrderAssignee.where { workOrder.id == workOrderId }.count()
        }
    }

    private void deleteScenario(Long workOrderId, Long requestId) {
        WorkOrder.withNewTransaction {
            WorkOrder workOrder = WorkOrder.get(workOrderId)
            if (workOrder) {
                WorkOrderAssignee.findAllByWorkOrder(workOrder)*.delete()
                workOrder.delete(flush: true)
            }
            CustomerRequest.get(requestId)?.delete(flush: true)
        }
    }
}

The two methods pin down the contract from both sides:

  • The happy path proves the writes commit as one unit. A SUBMITTED request with an OPEN work order gets an assignee; afterwards the work order is PLANNED and the request is IN_PROGRESS. All three landed.

  • The failure path proves the writes roll back as one unit. The scenario starts the request COMPLETED - a terminal state. Assigning an employee inserts the assignee and flips the work order to PLANNED, then the listener’s isTerminal() guard throws. The thrown(IllegalStateException) block catches it; the and: block then proves the rollback was total: no assignee row, the work order is still OPEN, the request is still COMPLETED.

That second assertion - work order back to OPEN after the listener had set it PLANNED - is the heart of the test. The status change was made, in memory, in the session, and then undone by the rollback, because it never belonged to a committed transaction.

A few details that differ from the AFTER_COMMIT spec earlier in this guide:

  • The failure is raised naturally, not simulated. The earlier spec called status.setRollbackOnly() to model a user-initiated rollback, because for AFTER_COMMIT the whole point was that the listener never ran. Here the listener is supposed to run, and its own business guard is what fails the transaction - so the test triggers the real exception path rather than faking a rollback.

  • Assertions read committed state in fresh transactions. Each then:/and: check wraps its read in withNewTransaction { …​ }. After a rollback, the mutated-then-discarded instances may still be cached on the session that ran the service call; reading in a new transaction guarantees the assertion sees what actually committed to the database, not a stale first-level-cache copy.

  • Setup and cleanup commit explicitly. As before, the spec is not @Rollback - it opens its own committed transactions with withNewTransaction and tears the rows down in cleanup: in dependency order (assignees, then the work order, then the request).

Run the spec:

./gradlew integrationTest --tests example.WorkOrderAssignmentIntegrationSpec

Both methods should pass. Together with OrderServiceIntegrationSpec, the app now demonstrates both halves of the pattern: AFTER_COMMIT for side-effects that react to a durable commit, and a synchronous @EventListener for cross-domain orchestration that is part of the atomic unit of work.

Files touched in this chapter:

  • src/integration-test/groovy/example/WorkOrderAssignmentIntegrationSpec.groovy

18 Testing the Pattern Inside and Out

Event-driven, transaction-bound behaviour is exactly the kind of thing that looks correct in isolation and breaks at the seams. The sample app tests it at four layers, fastest first.

Unit: domain constraints and the status state machine

Plain Spock specs with DomainUnitTest / DataTest verify every constraint, and a no-Grails spec pins the enum state machine the orchestration relies on. These run in milliseconds with no database.

src/test/groovy/example/CustomerRequestStatusSpec.groovy
package example

import spock.lang.Specification
import spock.lang.Unroll

class CustomerRequestStatusSpec extends Specification {

    @Unroll
    void "#status terminal flag is #expected"() {
        expect:
        status.terminal == expected

        where:
        status                            || expected
        CustomerRequestStatus.SUBMITTED   || false
        CustomerRequestStatus.IN_PROGRESS || false
        CustomerRequestStatus.COMPLETED   || true
        CustomerRequestStatus.CANCELLED   || true
    }

    void "the enum exposes exactly the four expected states"() {
        expect:
        CustomerRequestStatus.values()*.name() as Set ==
                ['SUBMITTED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'] as Set
    }
}

The sample repo carries one spec per domain (CustomerSpec, OrderSpec, AuditLogSpec, CustomerRequestSpec, WorkOrderSpec, WorkOrderAssigneeSpec). One worth calling out: a blank string set through the map constructor is converted to null by Grails data binding, so a blank: false field reports a nullable error rather than blank. To test the blank constraint itself, assign the property directly.

Integration: the transactional contract

The two @Integration specs earlier in this guide - the AFTER_COMMIT spec and the atomic-rollback spec - are the heart of the suite. They run against a real PlatformTransactionManager and a Testcontainers PostgreSQL database because the commit/rollback behaviour is invisible without one. They also cover the edge cases: a second assignment that must not re-run the cascade, and a closed request that must roll the whole operation back.

Functional: the REST endpoints over real HTTP

Two controllers expose the operations so the flows can be driven over HTTP:

grails-app/controllers/example/OrderController.groovy
package example

import groovy.json.JsonOutput
import org.springframework.http.HttpStatus

/**
 * REST entry point for placing an order. Delegates to OrderService, whose
 * @Transactional method publishes OrderPlacedEvent; the AFTER_COMMIT
 * listeners then fan out once this request's transaction commits.
 */
class OrderController {

    OrderService orderService

    def place() {
        Order order = orderService.placeOrder(params.long('customerId'), params.total as BigDecimal)
        response.status = HttpStatus.CREATED.value()
        render(text: JsonOutput.toJson([
                id        : order.id,
                customerId: order.customer.id,
                total     : order.total
        ]), contentType: 'application/json')
    }
}
grails-app/controllers/example/WorkOrderController.groovy
package example

import groovy.json.JsonOutput
import org.springframework.http.HttpStatus

/**
 * REST entry point for the synchronous orchestration. assign() delegates to
 * WorkOrderAssignmentService, whose @Transactional method publishes
 * EmployeeAssignedEvent; the plain @EventListener cascades the WorkOrder and
 * CustomerRequest status changes inline, in the same transaction. A business
 * rule violation (assigning work to a closed request) surfaces as 409 and
 * rolls the whole operation back.
 */
class WorkOrderController {

    WorkOrderAssignmentService workOrderAssignmentService

    def show() {
        Long workOrderId = params.long('workOrderId')
        WorkOrder workOrder = WorkOrder.get(workOrderId)
        if (!workOrder) {
            response.status = HttpStatus.NOT_FOUND.value()
            render(text: JsonOutput.toJson([error: "No work order ${workOrderId}".toString()]),
                    contentType: 'application/json')
            return
        }
        render(text: JsonOutput.toJson([
                id                   : workOrder.id,
                status               : workOrder.status.name(),
                customerRequestId    : workOrder.customerRequest.id,
                customerRequestStatus: workOrder.customerRequest.status.name()
        ]), contentType: 'application/json')
    }

    def assign() {
        Long workOrderId = params.long('workOrderId')
        try {
            WorkOrderAssignee assignee = workOrderAssignmentService.assignEmployee(workOrderId, params.employeeName)
            WorkOrder workOrder = WorkOrder.get(workOrderId)
            response.status = HttpStatus.CREATED.value()
            render(text: JsonOutput.toJson([
                    assigneeId           : assignee.id,
                    workOrderId          : workOrder.id,
                    workOrderStatus      : workOrder.status.name(),
                    customerRequestId    : workOrder.customerRequest.id,
                    customerRequestStatus: workOrder.customerRequest.status.name()
            ]), contentType: 'application/json')
        } catch (IllegalStateException e) {
            // The orchestration rolled the whole transaction back before this
            // catch ran; we just translate it into a clean 409 response.
            response.status = HttpStatus.CONFLICT.value()
            render(text: JsonOutput.toJson([error: e.message]), contentType: 'application/json')
        }
    }
}

The functional spec sends real HTTP requests to the booted app (port injected via @Value('${local.server.port}')) and asserts both the response and the committed database state - including that a closed request yields 409 Conflict with nothing persisted.

src/integration-test/groovy/example/WorkOrderAssignmentFunctionalSpec.groovy
package example

import grails.testing.mixin.integration.Integration
import groovy.json.JsonSlurper
import org.springframework.beans.factory.annotation.Value
import spock.lang.Specification

import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

/**
 * End-to-end functional test of the synchronous orchestration over real HTTP.
 * The happy path proves the cascade commits as one unit; the 409 path proves
 * a business-rule violation rolls the whole operation back - assignee, work
 * order status, and request status all unchanged - through the full web stack.
 */
@Integration
class WorkOrderAssignmentFunctionalSpec extends Specification {

    @Value('${local.server.port}')
    Integer serverPort

    private final HttpClient client = HttpClient.newHttpClient()

    void "POST /assignments plans the work order and advances the request"() {
        given:
        Long[] ids = createScenario(CustomerRequestStatus.SUBMITTED)
        Long requestId = ids[0]
        Long workOrderId = ids[1]

        when:
        HttpResponse<String> resp = post('/assignments', "workOrderId=${workOrderId}&employeeName=Dana+Reed")

        then: 'the POST reports the atomic cascade'
        resp.statusCode() == 201
        with(new JsonSlurper().parseText(resp.body())) {
            workOrderStatus == 'PLANNED'
            customerRequestStatus == 'IN_PROGRESS'
        }

        and: 'a fresh GET confirms the committed state'
        with(new JsonSlurper().parseText(get("/workOrderStatus?workOrderId=${workOrderId}").body())) {
            status == 'PLANNED'
            customerRequestStatus == 'IN_PROGRESS'
        }

        cleanup:
        deleteScenario(workOrderId, requestId)
    }

    void "POST against a closed request returns 409 and rolls every change back"() {
        given:
        Long[] ids = createScenario(CustomerRequestStatus.CANCELLED)
        Long requestId = ids[0]
        Long workOrderId = ids[1]
        long assigneesBefore = countAssignees(workOrderId)

        when:
        HttpResponse<String> resp = post('/assignments', "workOrderId=${workOrderId}&employeeName=Dana+Reed")

        then: 'the endpoint reports the conflict'
        resp.statusCode() == 409

        and: 'nothing persisted - no assignee, both statuses untouched'
        countAssignees(workOrderId) == assigneesBefore
        WorkOrder.withNewTransaction { WorkOrder.get(workOrderId).status } == WorkOrderStatus.OPEN
        CustomerRequest.withNewTransaction { CustomerRequest.get(requestId).status } == CustomerRequestStatus.CANCELLED

        cleanup:
        deleteScenario(workOrderId, requestId)
    }

    private Long[] createScenario(CustomerRequestStatus requestStatus) {
        Long requestId = null
        Long workOrderId = null
        CustomerRequest.withNewTransaction {
            CustomerRequest request = new CustomerRequest(
                    summary: 'Replace the rooftop HVAC unit', status: requestStatus)
                    .save(flush: true, failOnError: true)
            WorkOrder workOrder = new WorkOrder(
                    customerRequest: request, description: 'Site survey and install')
                    .save(flush: true, failOnError: true)
            requestId = request.id
            workOrderId = workOrder.id
        }
        [requestId, workOrderId] as Long[]
    }

    private long countAssignees(Long workOrderId) {
        WorkOrderAssignee.withNewTransaction {
            WorkOrderAssignee.where { workOrder.id == workOrderId }.count()
        }
    }

    private void deleteScenario(Long workOrderId, Long requestId) {
        WorkOrder.withNewTransaction {
            WorkOrder workOrder = WorkOrder.get(workOrderId)
            if (workOrder) {
                WorkOrderAssignee.findAllByWorkOrder(workOrder)*.delete()
                workOrder.delete(flush: true)
            }
            CustomerRequest.get(requestId)?.delete(flush: true)
        }
    }

    private HttpResponse<String> post(String path, String form) {
        HttpRequest req = HttpRequest.newBuilder()
                .uri(URI.create("http://localhost:${serverPort}${path}"))
                .header('Content-Type', 'application/x-www-form-urlencoded')
                .POST(HttpRequest.BodyPublishers.ofString(form))
                .build()
        client.send(req, HttpResponse.BodyHandlers.ofString())
    }

    private HttpResponse<String> get(String path) {
        HttpRequest req = HttpRequest.newBuilder()
                .uri(URI.create("http://localhost:${serverPort}${path}"))
                .GET()
                .build()
        client.send(req, HttpResponse.BodyHandlers.ofString())
    }
}

Browser: the full stack through Geb

The top of the pyramid drives a real Selenium-Chrome browser (started in a Testcontainers container by grails.plugin.geb.ContainerGebSpec) against a minimal HTML screen, asserting the cascaded statuses a user actually sees. A small controller renders the page and form inline:

grails-app/controllers/example/WorkOrderUiController.groovy
package example

/**
 * Minimal HTML UI for the work-order assignment flow, used by the Geb
 * browser tests. Renders plain HTML inline (no GSP) so the rest-api profile
 * stays untouched. index() shows the current status plus an assignment form;
 * assign() runs the synchronous orchestration and re-renders the page with
 * the updated (or, on a closed request, unchanged) status and a message.
 */
class WorkOrderUiController {

    WorkOrderAssignmentService workOrderAssignmentService

    def index() {
        render(text: page(params.long('workOrderId'), null), contentType: 'text/html')
    }

    def assign() {
        Long workOrderId = params.long('workOrderId')
        String message
        try {
            workOrderAssignmentService.assignEmployee(workOrderId, params.employeeName)
            message = "Assigned ${params.employeeName}"
        } catch (IllegalStateException e) {
            message = e.message
        }
        render(text: page(workOrderId, message), contentType: 'text/html')
    }

    private String page(Long workOrderId, String message) {
        WorkOrder workOrder = WorkOrder.get(workOrderId)
        if (!workOrder) {
            return '<html><body><h1>Work Order not found</h1></body></html>'
        }
        String messageHtml = message ? "<p id=\"message\">${message.encodeAsHTML()}</p>" : ''
        """<!doctype html>
<html>
<head><title>Work Order ${workOrder.id}</title></head>
<body>
  <h1>Work Order ${workOrder.id}</h1>
  <p>Work order status: <span id="workOrderStatus">${workOrder.status.name()}</span></p>
  <p>Request status: <span id="customerRequestStatus">${workOrder.customerRequest.status.name()}</span></p>
  ${messageHtml}
  <form method="post" action="/ui">
    <input type="hidden" name="workOrderId" value="${workOrder.id}"/>
    <label for="employeeName">Employee</label>
    <input type="text" id="employeeName" name="employeeName"/>
    <button type="submit" id="assignBtn">Assign</button>
  </form>
</body>
</html>"""
    }
}
src/integration-test/groovy/example/pages/WorkOrderPage.groovy
package example.pages

import geb.Page

/**
 * Geb page object for the work-order assignment screen served by
 * WorkOrderUiController. Navigated to with a workOrderId query param:
 * to(WorkOrderPage, workOrderId: 123).
 */
class WorkOrderPage extends Page {

    static url = '/ui'

    static at = { $('h1').text().startsWith('Work Order') }

    static content = {
        workOrderStatus { $('#workOrderStatus').text() }
        requestStatus { $('#customerRequestStatus').text() }
        employeeNameField { $('#employeeName') }
        assignButton { $('#assignBtn') }
        message(required: false) { $('#message') }
    }

    void assignEmployee(String name) {
        employeeNameField.value(name)
        assignButton.click()
    }
}
src/integration-test/groovy/example/WorkOrderAssignmentGebSpec.groovy
package example

import example.pages.WorkOrderPage
import grails.plugin.geb.ContainerGebSpec
import grails.testing.mixin.integration.Integration

/**
 * Browser-driven end-to-end test of the synchronous orchestration. A real
 * Selenium-Chrome browser (started in a Testcontainers container by
 * ContainerGebSpec) loads the work-order page, submits the assignment form,
 * and asserts the cascaded statuses the user actually sees - proving the
 * pattern works through the entire stack from the browser down to the DB.
 */
@Integration
class WorkOrderAssignmentGebSpec extends ContainerGebSpec {

    void "assigning an employee through the UI plans the work order and advances the request"() {
        given:
        Long[] ids = createScenario(CustomerRequestStatus.SUBMITTED)
        Long requestId = ids[0]
        Long workOrderId = ids[1]

        when: 'the work-order page is opened'
        to WorkOrderPage, workOrderId: workOrderId

        then: 'it shows the pre-assignment state'
        workOrderStatus == 'OPEN'
        requestStatus == 'SUBMITTED'

        when: 'an employee is assigned through the form'
        assignEmployee('Dana Reed')

        then: 'the page reflects the committed cascade'
        at WorkOrderPage
        workOrderStatus == 'PLANNED'
        requestStatus == 'IN_PROGRESS'

        cleanup:
        deleteScenario(workOrderId, requestId)
    }

    void "the UI surfaces a conflict and leaves every status unchanged for a closed request"() {
        given:
        Long[] ids = createScenario(CustomerRequestStatus.CANCELLED)
        Long requestId = ids[0]
        Long workOrderId = ids[1]

        when: 'an employee is assigned to a work order whose request is cancelled'
        to WorkOrderPage, workOrderId: workOrderId
        assignEmployee('Dana Reed')

        then: 'the conflict message shows and the statuses are unchanged'
        at WorkOrderPage
        message.text().contains('Cannot start work')
        workOrderStatus == 'OPEN'
        requestStatus == 'CANCELLED'

        and: 'nothing persisted'
        countAssignees(workOrderId) == 0

        cleanup:
        deleteScenario(workOrderId, requestId)
    }

    private Long[] createScenario(CustomerRequestStatus requestStatus) {
        Long requestId = null
        Long workOrderId = null
        CustomerRequest.withNewTransaction {
            CustomerRequest request = new CustomerRequest(
                    summary: 'Replace the rooftop HVAC unit', status: requestStatus)
                    .save(flush: true, failOnError: true)
            WorkOrder workOrder = new WorkOrder(
                    customerRequest: request, description: 'Site survey and install')
                    .save(flush: true, failOnError: true)
            requestId = request.id
            workOrderId = workOrder.id
        }
        [requestId, workOrderId] as Long[]
    }

    private long countAssignees(Long workOrderId) {
        WorkOrderAssignee.withNewTransaction {
            WorkOrderAssignee.where { workOrder.id == workOrderId }.count()
        }
    }

    private void deleteScenario(Long workOrderId, Long requestId) {
        WorkOrder.withNewTransaction {
            WorkOrder workOrder = WorkOrder.get(workOrderId)
            if (workOrder) {
                WorkOrderAssignee.findAllByWorkOrder(workOrder)*.delete()
                workOrder.delete(flush: true)
            }
            CustomerRequest.get(requestId)?.delete(flush: true)
        }
    }
}

ContainerGebSpec comes from testFixtures("org.apache.grails:grails-geb") and needs only a running Docker daemon - no local WebDriver binaries. The first run pulls the Selenium image (~200 MB); later runs reuse it.

Run it all:

./gradlew test            # unit - milliseconds
./gradlew integrationTest  # integration + functional + Geb (Docker required)

Files touched in this chapter:

  • grails-app/controllers/example/OrderController.groovy

  • grails-app/controllers/example/WorkOrderController.groovy

  • grails-app/controllers/example/WorkOrderUiController.groovy

  • grails-app/controllers/example/UrlMappings.groovy

  • src/test/groovy/example/*Spec.groovy

  • src/integration-test/groovy/example/OrderFunctionalSpec.groovy

  • src/integration-test/groovy/example/WorkOrderAssignmentFunctionalSpec.groovy

  • src/integration-test/groovy/example/WorkOrderAssignmentGebSpec.groovy

  • src/integration-test/groovy/example/pages/WorkOrderPage.groovy

19 Do you need help with Grails?

Help with Apache Grails

Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.