GORM Event Listeners
Learn to write and test GORM event listeners
Authors: Zachary Klein, Sergio del Amo
Grails Version: 8
1 Getting Started
In this guide you will learn how to write and test GORM event listeners on Apache Grails 8. GORM event listeners let you run custom logic when objects are saved, updated, or deleted — for example creating an audit log, updating a related object, or changing a property before it is persisted. These listeners use Grails events, with shortcuts that target domain-class persistence events.
You may already know the persistence callbacks on domain classes (beforeInsert, afterInsert, beforeUpdate, and so on). Event listeners do the same kind of work, but they live in the Spring context. That means they can call other services and beans. Domain classes are not autowired by default, so they are a poorer place for this logic.
|
Work in the initial/ project and compare your progress with complete/.
1.1 What you will need
-
Approximately 45 minutes
-
JDK 21 (Apache Grails 8 requires Java 21)
-
A text editor or IDE
-
The Gradle wrapper bundled in
initial/andcomplete/
1.2 How to complete the guide
Clone the companion and start from initial/:
git clone -b grails8 https://github.com/grails-guides/gorm-event-listeners.git
cd gorm-event-listeners/initial
./gradlew test
initial/ is a vanilla Grails 8 web starter. You will add the Book and Audit domains, GORM Data Services, and the listeners. To skip ahead, use complete/.
2 Writing the Application
The domain model is small. Two classes cover the sample:
Class |
Role |
Book |
Core domain model |
Audit |
Log messages that record persistence events for a given |
Create the domain classes and edit them as shown below:
package demo
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class Book {
String author
String title
String friendlyUrl
Integer pages
String serialNumber
static constraints = {
serialNumber nullable: true
friendlyUrl nullable: true
title nullable: false
pages min: 0
}
}
package demo
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class Audit {
String event
Long bookId
static constraints = {
event nullable: false, blank: false
bookId nullable: false
}
}
2.1 Data Services
To handle persistence (saving, updating, and deleting books and audits), create GORM Data Services.
Data Services keep queries and writes in one place. Instead of calling dynamic finders or updating domain objects from many classes, you declare the methods you need on an interface (or abstract class) and GORM supplies the implementation. Data Services are transactional Spring beans, so you can inject them into other services and controllers. The usual GORM method naming still applies — a method such as Book findByTitleAndPagesGreaterThan(String title, Long pages) is implemented the same way as the matching dynamic finder.
Why Data Services? You get compile-time type checking (the method above will not compile unless Book has a String title and a Long pages property) and you can use @CompileStatic. Centralizing queries also makes later performance work cheaper: improve one method, and every caller benefits.
|
Create the following files under grails-app/services/demo/:
package demo
import grails.gorm.services.Service
import grails.gorm.services.Where
import groovy.transform.CompileStatic
@CompileStatic
@Service(Audit)
interface AuditDataService {
Audit save(String event, Long bookId)
Number count()
List<Audit> findAll(Map args)
@Where({ bookId == id })
void deleteByBookId(Long id)
}
package demo
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@CompileStatic
@Service(Book)
interface BookDataService {
Book save(String title, String author, Integer pages)
List<Book> findAll()
Book update(Serializable id, String title)
void delete(Serializable id)
}
Both Data Services are interfaces with no implementation of your own. GORM fills in each method. When you need custom logic, define the Data Service as an abstract class and implement only those methods yourself (any remaining abstract methods are still generated by GORM).
2.2 Listening to events from GORM asynchronously
The first listener saves Audit rows whenever a Book is created, updated, or deleted.
Create grails-app/services/demo/AuditListenerService.groovy:
package demo
import grails.events.annotation.Subscriber
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.PostDeleteEvent
import org.grails.datastore.mapping.engine.event.PostInsertEvent
import org.grails.datastore.mapping.engine.event.PostUpdateEvent
@Slf4j
@CompileStatic
class AuditListenerService {
AuditDataService auditDataService
Long bookId(AbstractPersistenceEvent event) {
if ( event.entityObject instanceof Book ) {
return ((Book) event.entityObject).id (2)
}
null
}
@Subscriber (1)
void afterInsert(PostInsertEvent event) {
Long bookId = bookId(event)
if ( bookId ) {
log.info 'After book save...'
auditDataService.save('Book saved', bookId)
}
}
@Subscriber (1)
void afterUpdate(PostUpdateEvent event) { (3)
Long bookId = bookId(event)
if ( bookId ) {
log.info "After book update..."
auditDataService.save('Book updated', bookId)
}
}
@Subscriber (1)
void afterDelete(PostDeleteEvent event) {
Long bookId = bookId(event)
if ( bookId ) {
log.info 'After book delete ...'
auditDataService.save('Book deleted', bookId)
}
}
}
| 1 | @Subscriber plus the method signature select the event. A method named afterInsert that takes a PostInsertEvent runs after an object is saved. |
| 2 | event.entityObject is the domain instance that fired the event. Cast it to Book to read the id used as Audit.bookId. |
| 3 | A PostUpdateEvent argument means this method runs after an update. |
bookId returns null for any entity that is not a Book. This service therefore writes Audit rows only for Book insert, update, and delete operations.
|
Write a unit test that verifies auditDataService is called for PostInsertEvent, PostUpdateEvent, and PostDeleteEvent. GORM Data Services help here: as interfaces they are easy to mock.
package demo
import grails.testing.gorm.DataTest
import grails.testing.services.ServiceUnitTest
import org.grails.datastore.mapping.engine.event.PostDeleteEvent
import org.grails.datastore.mapping.engine.event.PostInsertEvent
import org.grails.datastore.mapping.engine.event.PostUpdateEvent
import spock.lang.Specification
class AuditListenerServiceSpec extends Specification implements ServiceUnitTest<AuditListenerService>, DataTest { (1)
void setupSpec() {
mockDomains Book (2)
}
void "Book.PostInsertEvent triggers auditDataService.save"(){
given:
service.auditDataService = Mock(AuditDataService)
Book book = new Book(title: 'Practical Grails 3',
author: 'Eric Helgeson',
pages: 1).save() (3)
PostInsertEvent event = new PostInsertEvent(datastore, book) (4)
when:
service.afterInsert(event) (5)
then:
1 * service.auditDataService.save(_, _) (6)
}
void "Book.PostUpdateEvent triggers auditDataService.save"(){
given:
service.auditDataService = Mock(AuditDataService)
Book book = new Book(title: 'Practical Grails 3',
author: 'Eric Helgeson',
pages: 1).save() (3)
PostUpdateEvent event = new PostUpdateEvent(datastore, book) (4)
when:
service.afterUpdate(event) (5)
then:
1 * service.auditDataService.save(_, _) (6)
}
void "Book.PostDeleteEvent triggers auditDataService.save"(){
given:
service.auditDataService = Mock(AuditDataService)
Book book = new Book(title: 'Practical Grails 3',
author: 'Eric Helgeson',
pages: 1).save() (3)
PostDeleteEvent event = new PostDeleteEvent(datastore, book) (4)
when:
service.afterDelete(event) (5)
then:
1 * service.auditDataService.save(_, _) (6)
}
}
| 1 | Implement grails.testing.services.ServiceUnitTest (unit-test services) and DataTest so GORM is available. |
| 2 | DataTest provides mockDomains, which registers the domain classes used in the test. |
| 3 | After DataTest has wired GORM, you can construct and save a Book. |
| 4 | Build a PostInsertEvent from the Book. datastore comes from DataTest (getDatastore()). |
| 5 | Call afterInsert with that event. |
| 6 | Assert that auditDataService.save() was invoked. |
Next, add an integration test that saving, updating, or deleting a book leaves an audit trail. Because @Subscriber handles GORM events asynchronously, the spec uses Spock PollingConditions, which retries assertions until they pass or the timeout expires.
package demo
import grails.testing.mixin.integration.Integration
import spock.lang.Specification
import spock.util.concurrent.PollingConditions
@Integration
class AuditListenerServiceIntegrationSpec extends Specification {
BookDataService bookDataService
AuditDataService auditDataService
void "saving a Book causes an Audit instance to be saved"() {
when:
def conditions = new PollingConditions(timeout: 30)
Book book = bookDataService.save('Practical Grails 3', 'Eric Helgeson', 1)
then:
book
book.id
conditions.eventually {
assert auditDataService.count() == old(auditDataService.count()) + 1
}
when:
Audit lastAudit = this.lastAudit()
then:
lastAudit.event == "Book saved"
lastAudit.bookId == book.id
when: 'A books is updated'
book = bookDataService.update(book.id, 'Grails 3')
then: 'a new audit instance is created'
conditions.eventually {
assert auditDataService.count() == old(auditDataService.count()) + 1
}
when:
lastAudit = this.lastAudit()
then:
book.title == 'Grails 3'
lastAudit.event == 'Book updated'
lastAudit.bookId == book.id
when: 'A book is deleted'
bookDataService.delete(book.id)
then: 'a new audit instance is created'
conditions.eventually {
assert auditDataService.count() == old(auditDataService.count()) + 1
}
when:
lastAudit = this.lastAudit()
then:
lastAudit.event == 'Book deleted'
lastAudit.bookId == book.id
cleanup:
auditDataService.deleteByBookId(book.id)
}
Audit lastAudit() {
int offset = Math.max(((auditDataService.count() as int) - 1), 0)
auditDataService.findAll([max: 1, offset: offset]).first()
}
}
2.3 Listening to events from GORM synchronously
Often you want to read or change properties on the domain object inside a listener — encode a password before save, or reject a title against a blacklist. GORM events expose entityAccess, which gets and sets properties on the entity that triggered the event.
The next listener assigns a serial number and a friendly URL to each Book. The serial number is a random eight-letter string prefixed by the first characters of the title. A book titled Groovy in Action might get GROO-WKVLEQED. Instead of /book/show/1, the friendly URL looks like /book/practical-grails-3.
Create grails-app/services/demo/SerialNumberGeneratorService.groovy:
package demo
import groovy.transform.CompileStatic
@CompileStatic
class SerialNumberGeneratorService {
private static final String LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
private final Random random = new Random()
String generate(String bookTitle) {
StringBuilder randomString = new StringBuilder(8)
for (int i = 0; i < 8; i++) {
randomString.append(LETTERS.charAt(random.nextInt(LETTERS.length())))
}
String titleChars = "${bookTitle}".take(4) (1)
"${titleChars}-${randomString}".toUpperCase()
}
}
| 1 | take safely returns the first 4 characters. It does not throw if the title is shorter than that. |
Create grails-app/services/demo/FriendlyUrlService.groovy:
package demo
import groovy.transform.CompileStatic
import java.text.Normalizer
@CompileStatic
class FriendlyUrlService {
/**
* This method transforms the text passed as an argument to a text without spaces,
* html entities, accents, dots and extranges characters (only %,a-z,A-Z,0-9, ,_ and - are allowed).
*
* Borrowed from Wordpress: file wp-includes/formatting.php, function sanitize_title_with_dashes
* http://core.svn.wordpress.org/trunk/wp-includes/formatting.php
*/
String sanitizeWithDashes(String text) {
if ( !text ) {
return ''
}
// Preserve escaped octets
text = text.replaceAll('%([a-fA-F0-9][a-fA-F0-9])','---$1---')
text = text.replaceAll('%','')
text = text.replaceAll('---([a-fA-F0-9][a-fA-F0-9])---','%$1')
// Remove accents
text = removeAccents(text)
// To lower case
text = text.toLowerCase()
// Kill entities
text = text.replaceAll('&.+?;','')
// Dots -> ''
text = text.replaceAll('\\.','')
// Remove any character except %a-zA-Z0-9 _-
text = text.replaceAll('[^%a-zA-Z0-9 _-]', '')
// Trim
text = text.trim()
// Spaces -> dashes
text = text.replaceAll('\\s+', '-')
// Dashes -> dash
text = text.replaceAll('-+', '-')
// It must end in a letter or digit, otherwise we strip the last char
if (text && !text[-1].charAt(0).isLetterOrDigit()) text = text[0..-2]
return text
}
/**
* Converts all accent characters to ASCII characters.
*
* If there are no accent characters, then the string given is just returned.
*
*/
private String removeAccents(String text) {
Normalizer.normalize(text, Normalizer.Form.NFD)
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "")
}
}
And a unit test for it:
package demo
import grails.testing.services.ServiceUnitTest
import spock.lang.Specification
import spock.lang.Unroll
class FriendlyUrlServiceSpec extends Specification implements ServiceUnitTest<FriendlyUrlService> {
@Unroll
def "Friendly Url for #title : #expected"(String title, String expected) {
expect:
expected == service.sanitizeWithDashes(title)
where:
title | expected
'Practical Grails 3' | 'practical-grails-3'
}
}
Now add a listener for new and updated book titles. Create grails-app/services/demo/TitleListenerService.groovy.
Populate serialNumber synchronously on insert. For that, use @Listener instead of @Subscriber.
package demo
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
@CompileStatic
class TitleListenerService {
FriendlyUrlService friendlyUrlService
SerialNumberGeneratorService serialNumberGeneratorService
@Listener(Book) (1)
void onBookPreInsert(PreInsertEvent event) {
populateSerialNumber(event)
populateFriendlyUrl(event)
}
@Listener(Book) (1)
void onBookPreUpdate(PreUpdateEvent event) { (2)
Book book = ((Book) event.entityObject)
if ( book.isDirty('title') ) { (3)
populateFriendlyUrl(event)
}
}
void populateSerialNumber(AbstractPersistenceEvent event) {
String title = event.entityAccess.getProperty('title') as String (4)
String serialNumber = serialNumberGeneratorService.generate(title)
event.entityAccess.setProperty('serialNumber', serialNumber) (5)
}
void populateFriendlyUrl(AbstractPersistenceEvent event) {
String title = event.entityAccess.getProperty('title') as String
String friendlyUrl = friendlyUrlService.sanitizeWithDashes(title)
event.entityAccess.setProperty('friendlyUrl', friendlyUrl)
}
}
| 1 | @Listener turns the method into a synchronous GORM listener. When GORM fires a persistence event, matching @Listener methods run. The annotation value is a domain class (or list of classes) to listen for — here, only Book events. |
| 2 | onBookPreUpdate runs when a book is dirty (a property changed). If title changed, refresh friendlyUrl. |
| 3 | isDirty reports which properties changed on the persisted object. |
| 4 | Read title with event.entityAccess.getProperty(). |
| 5 | Write serialNumber with event.entityAccess.setProperty(). |
Do not cast event.entityObject to Book and assign serialNumber directly. That assignment can fire another event and re-enter the same listener. entityAccess applies the change in the current persistence session so it is saved with the original object.
|
GORM Data Services also make this listener easy to unit-test. Stub the generator and assert that the serial number is set:
package demo
import grails.testing.gorm.DataTest
import grails.testing.services.ServiceUnitTest
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.springframework.test.annotation.Rollback
import spock.lang.Specification
class TitleListenerServiceSpec extends Specification implements ServiceUnitTest<TitleListenerService>, DataTest {
def setupSpec() {
mockDomain Book
}
Closure doWithSpring() {{ -> (1)
friendlyUrlService(FriendlyUrlService)
}}
@Rollback
void "test serial number generated"() {
given:
Book book = new Book(title: 'Practical Grails 3', author: 'Eric Helgeson', pages: 100)
when:
service.serialNumberGeneratorService = Stub(SerialNumberGeneratorService) {
generate(_ as String) >> 'XXXX-5125'
}
service.onBookPreInsert(new PreInsertEvent(datastore, book))
then:
book.serialNumber == 'XXXX-5125'
book.friendlyUrl == 'practical-grails-3'
}
}
| 1 | Override doWithSpring to add or replace beans in the test context. |
Integration Testing
Create an integration test that friendlyUrl updates when title changes:
package demo
import grails.testing.mixin.integration.Integration
import spock.lang.Specification
@Integration
class TitleListenerServiceIntegrationSpec extends Specification {
BookDataService bookDataService
AuditDataService auditDataService
def "saving a book, generates automatically a serial number"() {
when:
Book book = bookDataService.save('Practical Grails 3', 'Eric Helgeson', 100)
String serialNumber = book.serialNumber
String friendlyUrl = book.friendlyUrl
then:
book
!book.hasErrors()
serialNumber
friendlyUrl == 'practical-grails-3'
when: 'updating book title'
book = bookDataService.update(book.id, 'Grails 3')
then: 'serial number stays the same'
serialNumber == book.serialNumber
and: 'friendly url changes'
friendlyUrl != book.friendlyUrl
book.friendlyUrl == 'grails-3'
cleanup:
auditDataService.deleteByBookId(book.id)
bookDataService.delete(book.id)
}
}
Advanced Unit Testing
An event-handling test is usually an integration test, as above. You can still write an equivalent unit test by wiring only the pieces you need — GORM, a few Spring beans, and the event listeners — without starting the whole application.
Create src/test/groovy/demo/TitleListenerServiceGrailsUnitSpec.groovy:
package demo
import grails.gorm.transactions.Rollback
import org.grails.orm.hibernate.HibernateDatastore
import org.grails.testing.GrailsUnitTest
import org.springframework.transaction.PlatformTransactionManager
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
class TitleListenerServiceGrailsUnitSpec extends Specification implements GrailsUnitTest { (1)
@Shared
@AutoCleanup
HibernateDatastore hibernateDatastore (2)
@Shared
PlatformTransactionManager transactionManager
void setupSpec() {
hibernateDatastore = applicationContext.getBean(HibernateDatastore) (2)
transactionManager = hibernateDatastore.getTransactionManager()
}
@Override
Closure doWithSpring() { (3)
{ ->
friendlyUrlService(FriendlyUrlService)
serialNumberGeneratorService(SerialNumberGeneratorService)
titleListenerService(TitleListenerService) {
friendlyUrlService = ref('friendlyUrlService')
serialNumberGeneratorService = ref('serialNumberGeneratorService')
}
datastore(HibernateDatastore, [Book])
}
}
@Rollback
def "serialNumber and friendyUrl are populated after book is saved"() { (4)
when:
Book book = new Book(title: 'Practical Grails 3', author: 'Eric Helgeson', pages: 100)
book.save(flush: true)
then:
!book.hasErrors()
when:
book = Book.findByTitle('Practical Grails 3')
String serialNumber = book.serialNumber
String friendlyUrl = book.friendlyUrl
then:
serialNumber
friendlyUrl == 'practical-grails-3'
when: 'updating book title'
book.title = 'Grails 3'
book.save(flush: true)
then:
!book.hasErrors()
when:
book = Book.findByTitle('Grails 3')
then: 'serial number stays the same'
serialNumber == book.serialNumber
and: 'friendly url changes'
friendlyUrl != book.friendlyUrl
book.friendlyUrl == 'grails-3'
}
}
| 1 | Because the spec wires GORM, Spring, and events itself, implement the basic GrailsUnitTest trait rather than ServiceUnitTest. |
| 2 | Hold a @Shared @AutoCleanup datastore. In setupSpec, take the HibernateDatastore from applicationContext (provided by GrailsUnitTest) and keep its transactionManager. |
| 3 | In doWithSpring, register friendlyUrlService, serialNumberGeneratorService, titleListenerService, and a HibernateDatastore constructed with the domain classes this test needs. |
| 4 | The assertions then look like the integration test. |
3 Running the Tests
From complete/ (or your finished initial/):
./gradlew test
./gradlew integrationTest
Reports are written to build/reports/tests/.
4 Conclusion
Event listeners keep persistence side-effects out of your domain classes. @Subscriber is the async path — good for audit logs and other work that can finish after the transaction. @Listener is synchronous, which you need when the same save must also update properties such as serialNumber or friendlyUrl. GORM Data Services keep the persistence API small and easy to mock, so the same listeners are straightforward to unit- and integration-test on Grails 8.
5 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.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.