You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/06/01 01:16:58 UTC
(grails-static-website) branch simplify-transactional-events-listeners-as-services created (now 9f761688290)
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a change to branch simplify-transactional-events-listeners-as-services
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git
at 9f761688290 Simplify grails-transactional-events v8: listeners are plain services
This branch includes the following new commits:
new 9f761688290 Simplify grails-transactional-events v8: listeners are plain services
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(grails-static-website) 01/01: Simplify grails-transactional-events v8: listeners are plain services
Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch simplify-transactional-events-listeners-as-services
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git
commit 9f761688290b824d525840a575452dd0c3f773ef
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Sun May 31 21:02:24 2026 -0400
Simplify grails-transactional-events v8: listeners are plain services
The v8 guide registered its four Spring event listeners one-by-one in
resources.groovy (behind a long explanatory comment) because the classes
were named *Listener and so were not Grails artefacts. Renaming them to
*Service lets Grails auto-register them by convention - the simpler,
idiomatic approach:
- CustomerLifetimeValueListener -> CustomerLifetimeValueService
- AuditListener -> AuditService
- NotificationListener -> NotificationService
- WorkOrderAssignmentListener -> WorkOrderPlanningService
resources.groovy now declares only the TransactionalEventListenerFactory
bean - the one piece Grails genuinely does not provide, because it drives
transactions through GORM's @Transactional rather than Spring's
@EnableTransactionManagement.
The narrative is updated throughout: the registration chapter also explains
the src/main/groovy + @ComponentScan alternative, and the AFTER_COMMIT
guidance consistently uses withNewTransaction (Grails' @Transactional AST
transform would otherwise hide a listener method from Spring's scanner).
Assisted-by: claude-code:claude-4.8-opus
---
conf/guides.yml | 2 +-
.../v8/guide/asyncDispatch.adoc | 8 +--
.../v8/guide/domainModel.adoc | 2 +-
.../v8/guide/integrationTest.adoc | 2 +-
.../v8/guide/multipleListeners.adoc | 8 +--
.../v8/guide/registeringListeners.adoc | 37 +++++++++-----
.../v8/guide/sameTransactionProblem.adoc | 6 +--
.../v8/guide/synchronousOrchestration.adoc | 8 +--
.../v8/guide/transactionPhases.adoc | 2 +-
.../v8/guide/transactionalListener.adoc | 10 ++--
.../v8/guide/whatYouWillBuild.adoc | 8 +--
.../grails-app/conf/spring/resources.groovy | 25 +++------
.../{AuditListener.groovy => AuditService.groovy} | 17 ++++---
.../example/CustomerLifetimeValueListener.groovy | 35 -------------
.../example/CustomerLifetimeValueService.groovy | 34 +++++++++++++
...nListener.groovy => NotificationService.groovy} | 20 ++++----
.../example/WorkOrderAssignmentListener.groovy | 59 ----------------------
.../example/WorkOrderPlanningService.groovy | 45 +++++++++++++++++
18 files changed, 156 insertions(+), 172 deletions(-)
diff --git a/conf/guides.yml b/conf/guides.yml
index c6ff59fe2d5..de8a141c28b 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -4716,7 +4716,7 @@ guides:
transactionalListener:
title: The First @TransactionalEventListener
registeringListeners:
- title: Registering the Listeners (and the Factory That Makes Them Fire)
+ title: The One Bean That Makes the Listeners Fire
transactionPhases:
title: The Four TransactionPhase Values
multipleListeners:
diff --git a/guides/grails-transactional-events/v8/guide/asyncDispatch.adoc b/guides/grails-transactional-events/v8/guide/asyncDispatch.adoc
index a339d5cf65c..af008c80b82 100644
--- a/guides/grails-transactional-events/v8/guide/asyncDispatch.adoc
+++ b/guides/grails-transactional-events/v8/guide/asyncDispatch.adoc
@@ -1,9 +1,9 @@
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.
[source,groovy]
-.grails-app/services/example/NotificationListener.groovy
+.grails-app/services/example/NotificationService.groovy
----
-include::../snippets/grails-app/services/example/NotificationListener.groovy[]
+include::../snippets/grails-app/services/example/NotificationService.groovy[]
----
Two pieces of wiring this listener needs:
@@ -39,7 +39,7 @@ The composition order with `@TransactionalEventListener` is exact:
. Because this listener method is also `@Async`, the call is handed to the `taskExecutor` bean instead of running on the synchronization thread.
. 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 `@Transactional(propagation = REQUIRES_NEW)` on the listener method, exactly like the `CustomerLifetimeValueListener` did. 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.
+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.HibernateExce [...]
A few footguns:
@@ -54,5 +54,5 @@ References:
Files touched in this chapter:
-* `grails-app/services/example/NotificationListener.groovy`
+* `grails-app/services/example/NotificationService.groovy`
* `grails-app/init/example/Application.groovy` (add `@EnableAsync`)
diff --git a/guides/grails-transactional-events/v8/guide/domainModel.adoc b/guides/grails-transactional-events/v8/guide/domainModel.adoc
index 9b840c8c3ce..7a3c5db1481 100644
--- a/guides/grails-transactional-events/v8/guide/domainModel.adoc
+++ b/guides/grails-transactional-events/v8/guide/domainModel.adoc
@@ -20,7 +20,7 @@ include::../snippets/grails-app/domain/example/AuditLog.groovy[]
A few choices to point at:
-* `Customer.lifetimeValue` defaults to `BigDecimal.ZERO` and is constrained `min: BigDecimal.ZERO`. The `CustomerLifetimeValueListener` 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.
+* `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.
diff --git a/guides/grails-transactional-events/v8/guide/integrationTest.adoc b/guides/grails-transactional-events/v8/guide/integrationTest.adoc
index 454d84cbf3c..1b7322be0fc 100644
--- a/guides/grails-transactional-events/v8/guide/integrationTest.adoc
+++ b/guides/grails-transactional-events/v8/guide/integrationTest.adoc
@@ -11,7 +11,7 @@ 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 t [...]
* *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 `NotificationListener` 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.
+* *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:
diff --git a/guides/grails-transactional-events/v8/guide/multipleListeners.adoc b/guides/grails-transactional-events/v8/guide/multipleListeners.adoc
index 2f05d2a2e7e..a493b63c032 100644
--- a/guides/grails-transactional-events/v8/guide/multipleListeners.adoc
+++ b/guides/grails-transactional-events/v8/guide/multipleListeners.adoc
@@ -1,12 +1,12 @@
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.
[source,groovy]
-.grails-app/services/example/AuditListener.groovy
+.grails-app/services/example/AuditService.groovy
----
-include::../snippets/grails-app/services/example/AuditListener.groovy[]
+include::../snippets/grails-app/services/example/AuditService.groovy[]
----
-Notice that this file is independent of `CustomerLifetimeValueListener`. 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 `WebhookListener` class and adding one line to `resources.groovy` to register it as a bean. Spring then wires both against the `OrderPlacedEvent` type and calls both (in unspecified order) when an order commits; the publisher never changes.
+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:
@@ -18,4 +18,4 @@ A common temptation is to add a `@Order(0)` annotation here to guarantee the aud
Files touched in this chapter:
-* `grails-app/services/example/AuditListener.groovy`
+* `grails-app/services/example/AuditService.groovy`
diff --git a/guides/grails-transactional-events/v8/guide/registeringListeners.adoc b/guides/grails-transactional-events/v8/guide/registeringListeners.adoc
index 814e4964cf9..6b3f0586392 100644
--- a/guides/grails-transactional-events/v8/guide/registeringListeners.adoc
+++ b/guides/grails-transactional-events/v8/guide/registeringListeners.adoc
@@ -1,28 +1,39 @@
-There is a wiring step that is easy to miss and fails *silently* when you do: the listener beans have to be registered, and `@TransactionalEventListener` needs one extra bean before Spring will honour it.
+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.
-== grails-app/services/ does not auto-register a class unless it ends in `Service`
+[NOTE]
+.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.
-Grails turns a class under `grails-app/services/` into a Spring bean only when its name ends in `Service`. Our listeners are named `...Listener`, so Grails does *not* register them. A class that is never a bean is never scanned by Spring's `EventListenerMethodProcessor`, so its `@EventListener` / `@TransactionalEventListener` methods are never wired - and nothing warns you. The publisher calls `publishEvent(...)`, no listener runs, and the only symptom is a side-effect that quietly never [...]
-
-Register the listeners explicitly:
+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.
[source,groovy]
-.grails-app/conf/spring/resources.groovy
----
-include::../snippets/grails-app/conf/spring/resources.groovy[]
+@ComponentScan('example') // <1>
+class Application extends GrailsAutoConfiguration { /* ... */ }
----
-(You could instead rename the classes to end in `Service` - `AuditListenerService` - and let Grails auto-register them. The explicit `resources.groovy` registration is clearer about intent and keeps the descriptive `Listener` names.)
+<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 [...]
+====
+
+There is exactly one bean you *do* have to declare, and forgetting it fails *silently*.
== `@TransactionalEventListener` needs a `TransactionalEventListenerFactory` bean
-Registering the bean is enough for a *plain* `@EventListener` to fire. It is **not** enough for `@TransactionalEventListener`. The transaction-phase-bound variant is only processed when a `TransactionalEventListenerFactory` bean exists in the context. In a standard Spring Boot application `@EnableTransactionManagement` contributes that factory automatically; in this Grails application it is not registered by default, so every `@TransactionalEventListener` is silently ignored until you de [...]
+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 regi [...]
+
+[source,groovy]
+.grails-app/conf/spring/resources.groovy
+----
+include::../snippets/grails-app/conf/spring/resources.groovy[]
+----
-The failure modes are worth committing to memory because they are all silent:
+That single line is the only wiring the pattern needs. The failure modes are worth committing to memory because they are both silent:
-* *Listener class not a `Spring` bean* (wrong suffix, not in `resources.groovy`) -> neither `@EventListener` nor `@TransactionalEventListener` fires.
-* *Bean registered but no `TransactionalEventListenerFactory`* -> plain `@EventListener` fires, 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.
-* *Both in place* -> everything fires.
+* *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.
diff --git a/guides/grails-transactional-events/v8/guide/sameTransactionProblem.adoc b/guides/grails-transactional-events/v8/guide/sameTransactionProblem.adoc
index 6500e1c14a9..6369f4c9d08 100644
--- a/guides/grails-transactional-events/v8/guide/sameTransactionProblem.adoc
+++ b/guides/grails-transactional-events/v8/guide/sameTransactionProblem.adoc
@@ -10,11 +10,11 @@ Take a field-service workflow with three domains:
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` [...]
-`AFTER_COMMIT` cannot express this. By the time an `AFTER_COMMIT` listener runs, the assignment has *already committed*; its `REQUIRES_NEW` transaction 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.
+`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 excepti [...]
-This is the mirror image of the first half of the guide, and it reuses the same machinery (`ApplicationEventPublisher`, a POGO event, a listener bean 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."
+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."
[cols="1,1", options="header"]
|===
@@ -23,7 +23,7 @@ This is the mirror image of the first half of the guide, and it reuses the same
| Fires after the publisher's commit
| Fires inline, the instant `publishEvent(...)` is called
-| Runs in a *new* transaction / session (needs `REQUIRES_NEW`)
+| Runs in a *new* transaction / session (opened with `withNewTransaction`)
| Runs in the publisher's transaction / session
| Reads committed data only
diff --git a/guides/grails-transactional-events/v8/guide/synchronousOrchestration.adoc b/guides/grails-transactional-events/v8/guide/synchronousOrchestration.adoc
index 3ca8620fd86..14eb717b945 100644
--- a/guides/grails-transactional-events/v8/guide/synchronousOrchestration.adoc
+++ b/guides/grails-transactional-events/v8/guide/synchronousOrchestration.adoc
@@ -17,15 +17,15 @@ include::../snippets/grails-app/services/example/WorkOrderAssignmentService.groo
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.
[source,groovy]
-.grails-app/services/example/WorkOrderAssignmentListener.groovy
+.grails-app/services/example/WorkOrderPlanningService.groovy
----
-include::../snippets/grails-app/services/example/WorkOrderAssignmentListener.groovy[]
+include::../snippets/grails-app/services/example/WorkOrderPlanningService.groovy[]
----
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.* No `REQUIRES_NEW`. The `@Transactional` here uses the default `REQUIRED` propagation, which *joins* the publisher's 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.
+* *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.
@@ -49,4 +49,4 @@ Files touched in this chapter:
* `src/main/groovy/example/events/EmployeeAssignedEvent.groovy`
* `grails-app/services/example/WorkOrderAssignmentService.groovy`
-* `grails-app/services/example/WorkOrderAssignmentListener.groovy`
+* `grails-app/services/example/WorkOrderPlanningService.groovy`
diff --git a/guides/grails-transactional-events/v8/guide/transactionPhases.adoc b/guides/grails-transactional-events/v8/guide/transactionPhases.adoc
index e9f60148bdb..3daa0bd906c 100644
--- a/guides/grails-transactional-events/v8/guide/transactionPhases.adoc
+++ b/guides/grails-transactional-events/v8/guide/transactionPhases.adoc
@@ -25,7 +25,7 @@ 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 comm [...]
-* *`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 via `@Transactional(propagation = REQUIRES_NEW)`.
+* *`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 meth [...]
* *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:
diff --git a/guides/grails-transactional-events/v8/guide/transactionalListener.adoc b/guides/grails-transactional-events/v8/guide/transactionalListener.adoc
index cfe4377b9c0..d90981ce57a 100644
--- a/guides/grails-transactional-events/v8/guide/transactionalListener.adoc
+++ b/guides/grails-transactional-events/v8/guide/transactionalListener.adoc
@@ -1,16 +1,16 @@
The first listener bumps `Customer.lifetimeValue` after an order commits.
[source,groovy]
-.grails-app/services/example/CustomerLifetimeValueListener.groovy
+.grails-app/services/example/CustomerLifetimeValueService.groovy
----
-include::../snippets/grails-app/services/example/CustomerLifetimeValueListener.groovy[]
+include::../snippets/grails-app/services/example/CustomerLifetimeValueService.groovy[]
----
Walk through it:
-* The class lives in `grails-app/services/`, but because its name ends in `Listener` rather than `Service`, Grails does *not* auto-register it as a Spring bean. You wire it up explicitly in `resources.groovy` - covered in the next chapter. Once it is a bean (and a `TransactionalEventListenerFactory` is present), Spring's `EventListenerMethodProcessor` scans its methods and registers the `@TransactionalEventListener`.
+* 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 declarativ [...]
+* 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 declarativ [...]
* 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.
@@ -18,4 +18,4 @@ One thing the listener deliberately doesn't do: re-publish another event. AFTER_
Files touched in this chapter:
-* `grails-app/services/example/CustomerLifetimeValueListener.groovy`
+* `grails-app/services/example/CustomerLifetimeValueService.groovy`
diff --git a/guides/grails-transactional-events/v8/guide/whatYouWillBuild.adoc b/guides/grails-transactional-events/v8/guide/whatYouWillBuild.adoc
index 95caa43905d..c3c81015e44 100644
--- a/guides/grails-transactional-events/v8/guide/whatYouWillBuild.adoc
+++ b/guides/grails-transactional-events/v8/guide/whatYouWillBuild.adoc
@@ -4,9 +4,9 @@ By the end of the guide you will have:
* 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:
-** `CustomerLifetimeValueListener` - bumps `Customer.lifetimeValue` after commit.
-** `AuditListener` - writes an `AuditLog` row after commit.
-** `NotificationListener` - dispatched off-thread via `@Async` after commit (a stand-in for sending email).
+** `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.
@@ -15,5 +15,5 @@ The second half of the guide builds the mirror-image pattern - cross-domain chan
* 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 `WorkOrderAssignmentListener` 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 `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.
diff --git a/guides/grails-transactional-events/v8/snippets/grails-app/conf/spring/resources.groovy b/guides/grails-transactional-events/v8/snippets/grails-app/conf/spring/resources.groovy
index 88ea14a5de7..7cb0e7f4641 100644
--- a/guides/grails-transactional-events/v8/snippets/grails-app/conf/spring/resources.groovy
+++ b/guides/grails-transactional-events/v8/snippets/grails-app/conf/spring/resources.groovy
@@ -1,22 +1,9 @@
-// Place your Spring DSL code here
beans = {
- // Grails only auto-registers a class under grails-app/services/ as a
- // Spring bean when its name ends in 'Service'. These listener classes end
- // in 'Listener', so Grails does NOT register them - which means Spring's
- // EventListenerMethodProcessor never scans their @EventListener /
- // @TransactionalEventListener methods and the listeners silently never
- // fire. Registering them explicitly here makes them beans so their
- // listener methods are wired at startup.
- customerLifetimeValueListener(example.CustomerLifetimeValueListener)
- auditListener(example.AuditListener)
- notificationListener(example.NotificationListener)
- workOrderAssignmentListener(example.WorkOrderAssignmentListener)
-
- // @TransactionalEventListener (the AFTER_COMMIT listeners) is only honored
- // when a TransactionalEventListenerFactory bean exists. Spring normally
- // contributes one through @EnableTransactionManagement, but a plain Grails
- // app does not register it, so the AFTER_COMMIT listeners would silently
- // never fire even though plain @EventListener beans do. Declaring it here
- // is what makes the transaction-bound phases work.
+ // 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)
}
diff --git a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/AuditListener.groovy b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/AuditService.groovy
similarity index 58%
rename from guides/grails-transactional-events/v8/snippets/grails-app/services/example/AuditListener.groovy
rename to guides/grails-transactional-events/v8/snippets/grails-app/services/example/AuditService.groovy
index 0df26dde384..8e533ab2e02 100644
--- a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/AuditListener.groovy
+++ b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/AuditService.groovy
@@ -7,16 +7,17 @@ import org.springframework.transaction.event.TransactionalEventListener
/**
* Persists an AuditLog row for every committed order.
*
- * A separate listener class - 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
- * class, not an edit to OrderService.
+ * 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 CustomerLifetimeValueListener, it is registered in resources.groovy
- * and does its GORM write inside withNewTransaction { } because the
- * AFTER_COMMIT callback fires after the publisher's transaction has committed.
+ * 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 AuditListener {
+class AuditService {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void onOrderPlaced(OrderPlacedEvent event) {
diff --git a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/CustomerLifetimeValueListener.groovy b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/CustomerLifetimeValueListener.groovy
deleted file mode 100644
index b1ab5feb6a4..00000000000
--- a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/CustomerLifetimeValueListener.groovy
+++ /dev/null
@@ -1,35 +0,0 @@
-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.
- *
- * Registered as a Spring bean in grails-app/conf/spring/resources.groovy -
- * Grails only auto-registers grails-app/services/ classes whose name ends in
- * 'Service', and this one ends in 'Listener'. That registration (plus the
- * TransactionalEventListenerFactory bean resources.groovy also declares) is
- * what lets Spring wire the @TransactionalEventListener method.
- *
- * 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 { }. An @Transactional(REQUIRES_NEW)
- * annotation does NOT work here: a bean registered in resources.groovy is not
- * wrapped in Grails' transactional proxy, so the annotation is ignored and
- * the save fails with "no transaction is in progress".
- */
-class CustomerLifetimeValueListener {
-
- @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)
- }
- }
- }
-}
diff --git a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/CustomerLifetimeValueService.groovy b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/CustomerLifetimeValueService.groovy
new file mode 100644
index 00000000000..edb98c52187
--- /dev/null
+++ b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/CustomerLifetimeValueService.groovy
@@ -0,0 +1,34 @@
+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)
+ }
+ }
+ }
+}
diff --git a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/NotificationListener.groovy b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/NotificationService.groovy
similarity index 60%
rename from guides/grails-transactional-events/v8/snippets/grails-app/services/example/NotificationListener.groovy
rename to guides/grails-transactional-events/v8/snippets/grails-app/services/example/NotificationService.groovy
index da2851bfc3d..d50030d4820 100644
--- a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/NotificationListener.groovy
+++ b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/NotificationService.groovy
@@ -10,22 +10,22 @@ import org.springframework.transaction.event.TransactionalEventListener
/**
* Off-thread side-effect: stand-in for sending a confirmation email.
*
- * @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 listener body therefore has NO inherited
- * transaction; if it needs the database it must open its own via
- * @Transactional(propagation = REQUIRES_NEW).
+ * An ordinary Grails service, auto-registered by name. @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.groovy for @Async to take
- * effect; without it the annotation is silently a no-op.
+ * @EnableAsync must be present on Application for @Async to take effect;
+ * without it the annotation is silently a no-op.
*/
-class NotificationListener {
+class NotificationService {
- private static final Logger log = LoggerFactory.getLogger(NotificationListener)
+ private static final Logger log = LoggerFactory.getLogger(NotificationService)
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
diff --git a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/WorkOrderAssignmentListener.groovy b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/WorkOrderAssignmentListener.groovy
deleted file mode 100644
index f1ef5baa112..00000000000
--- a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/WorkOrderAssignmentListener.groovy
+++ /dev/null
@@ -1,59 +0,0 @@
-package example
-
-import example.events.EmployeeAssignedEvent
-import org.springframework.context.event.EventListener
-
-/**
- * Synchronous, in-transaction orchestration.
- *
- * The annotation is a plain @EventListener - NOT @TransactionalEventListener.
- * Because WorkOrderAssignmentService publishes the event from inside its
- * @Transactional method, Spring's default (synchronous) multicaster invokes
- * this method inline, on the publisher's thread, inside the publisher's
- * transaction and Hibernate session.
- *
- * Three consequences make this the right tool for atomic cross-domain work:
- *
- * 1. Reads see in-flight data. WorkOrder.get(...) returns the managed
- * instance from the current session - including the assignee that was
- * added moments ago in this same transaction.
- * 2. Writes join the same commit. Mutating WorkOrder and CustomerRequest
- * here flushes as part of the publisher's commit.
- * 3. Failure rolls everything back. Any exception thrown here propagates
- * out through publishEvent(...) and unwinds the entire operation - the
- * assignee insert included.
- *
- * Note what is deliberately absent: there is NO @Transactional on this class.
- * The listener does not open or manage a transaction of its own - it runs
- * inside the one the publisher already started, which is the whole point.
- * (Adding Grails' @Transactional here would also be actively harmful: it is a
- * compile-time AST transform that relocates the method body, which hides the
- * @EventListener annotation from Spring's listener scanner so the listener
- * never registers.)
- */
-class WorkOrderAssignmentListener {
-
- @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)
- }
- }
-}
diff --git a/guides/grails-transactional-events/v8/snippets/grails-app/services/example/WorkOrderPlanningService.groovy b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/WorkOrderPlanningService.groovy
new file mode 100644
index 00000000000..dd61c4c07cb
--- /dev/null
+++ b/guides/grails-transactional-events/v8/snippets/grails-app/services/example/WorkOrderPlanningService.groovy
@@ -0,0 +1,45 @@
+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)
+ }
+ }
+}