Show Navigation

GORM Event Listeners

Learn to write and test GORM event listeners

Authors: Zachary Klein, Sergio del Amo

Grails Version: 5.0.1

1 Training

Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.

2 Getting Started

In this guide you will learn how to write and test GORM event listeners. GORM event listeners allow you to write methods that are called when objects are saved, updated, or deleted from the database, in order to perform custom logic (perhaps creating a log entry, updating/creating a related object, or modifying a property in the persisted object). These listeners leverage the async capabilities of Grails, but add some helpful shortcuts specific to capturing events from your domain classes.

You may be familiar with the persistence event handlers available on domain classes, such as beforeInsert, afterInsert, beforeUpdate, etc. Event listeners essentially allow you to perform the same sort of logic that you might have placed in these domain class methods. However, event listeners generally promote better code organziation, and because they are added to the Spring context, they can call service methods and other Spring beans, unlike domain classes (which by default are not autowired and do not participate in dependency-injection).

2.1 What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 1.8 or greater installed with JAVA_HOME configured appropriately

2.2 How to complete the guide

To get started do the following:

or

The Grails guides repositories contain two folders:

  • initial Initial project. Often a simple Grails app with some additional code to give you a head-start.

  • complete A completed example. It is the result of working through the steps presented by the guide and applying those changes to the initial folder.

To complete the guide, go to the initial folder

  • cd into grails-guides/gorm-event-listeners/initial

and follow the instructions in the next sections.

You can go right to the completed example if you cd into grails-guides/gorm-event-listeners/complete

3 Writing the Application

Our application’s domain model is fairly simple. We will create four domain classes, Book, Tag, BookTag, and Audit. The roles of these classes are described in the table below:

Table 1. Domain Classes

Class

Role

Book

Core domain model

Audit

Log messages to record persistence events for a given Book

Create the domain classes and edit them as shown below:

grails-app/domain/demo/Book.groovy
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
        serialNumber nullable: true
    }
}
grails-app/domain/demo/Audit.groovy
package demo

import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
class Audit {

    String event
    Long bookId

    static constraints = {
        event nullable: false, blank: false
        bookId nullable: false
    }
}

3.1 Data Services

In order to handle the persistence logic in our application (e.g., updating and deleting books and tags), we will create several GORM Data Services.

Data Services allow us to centralize the data access and persistence functions of our application. Rather than calling dynamic finders or updating our domain objects directly, we can define the kinds of queries and persistence actions that we need in an interface (or abstract class), allowing GORM to provide the implementation. Data Services are transactional and can be injected into other services or controllers just like any other Spring Bean. All the same GORM "magic" is at work - e.g., within a Service we can specify a method such as Book findByTitleAndPagesGreaterThan(String title, Long pages), and GORM will provide the same sort of implementation that you would get using a dynamic finder with the same name.

Why use Data Services? The primary advantages of GORM Data Services over dynamic finders are type-checking (the method shown above will not compile if there is not a String title and Long pages property on the Book class) and the ability to be statically compiled. In addition, there may be some architectural benefits to centralizing common queries and updates in a Data Service - should you choose to optimize a query within a Data Service for better performance, all of your code that uses that method can benefit from the change, without requiring you to track down each dynamic finder or where query and update them individually.

Create the following files under grails-app/services/demo/:

~ touch AuditDataService.groovy
~ touch BookDataService.groovy

Edit the files as shown below:

grails-app/services/demo/AuditDataService.groovy
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)
}
grails-app/services/demo/BookDataService.groovy
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)
}

Notice that the two Data Services above are interfaces, without any implementation of our own. This means that GORM will supply its default implementation for each of the methods in the class. However, there may be times when you need to supply some custom logic within a Data Service method. To accomplish this, we can define our Data Service as an abstract class, and create non-abstract methods to handle our custom code (any abstract methods will still be implemented by GORM).

3.2 Listening to events from GORM asynchronously

Our first listener will save Audit instances whenever a new Book is created, as well as when a Book is updated and deleted.

Create a new Grails service, named AuditListenerService:

~ grails create-service demo.AuditListenerService
A Grails service is not required in order to write an event listener. You could place the same methods we are about to write in a Groovy class under src/main/groovy. However, the listener does need to be wired into the Spring context, so we would have to do that manually if we did not use a Grails service. We will use Grails services for convenience.

Edit AuditListenerService as shown below:

grails-app/services/demo/AuditListenerService.groovy
package demo

import grails.events.annotation.Subscriber
import grails.events.annotation.gorm.Listener
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 The Subscriber annotation and the method signature indicate what event this method is interested in - e.g., a method named afterInsert with an argument of type PostInsertEvent means this method will only be called after an object is saved.
2 We can access the domain object that fired the event, via the event.entityObject. In order to access the id of this object, we cast it as Book and obtain the id, which we use as the bookId property of our Audit instance.
3 Again, the method signature here indicates that this method will be called for events of type PostUpdateEvent - after an object is updated.
Keep in mind that the method bookId will return null for any classes but Book entities. Thus, the previous class creates Audit instances for Book insert, update or delete operations.

Write a unit test to verify the auditDataService is invoked whenever a Book event of type PostInsertEvent, PostUpdateEvent or PostDeleteEvent is received. The next illustrates another big advantage of GORM Data Services. They create easy to test scenarios. Being interfaces they are easy to Mock.

src/test/groovy/demo/AuditListenerServiceSpec.groovy
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 We implement grails.testing.services.ServiceUnitTest (a trait to unit test services) and DataTest trait as well since we will be using GORM features.
2 The DataTest trait provides a mockDomains method, which we supply with the domain classes we intend to use in our test.
3 Because DataTest has wired up GORM for us, we can simply create a new instance of our Book class.
4 We can now use the Book instance to set up a PostInsertEvent (note that the dataStore property is available in our test, again as a result of implementing the DataTest trait).
5 We can now call the afterSave method, passing in the event.
6 We now can assert that our Audit instance was saved (via the auditDataService.save() method call).

Next, create an integration tests to verify that saving, updating or deleting a book leaves an audit trail. Since our service is capturing GORM Events asynchronously, are using Spock Polling Conditions, which will repeatedly evaluate one or more conditions until they are satisfied or a timeout has elapsed.

src/integration-test/groovy/demo/AuditListenerServiceIntegrationSpec.groovy
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()
    }
}

3.3 Listening to events from GORM synchronously

Often you will want to access and even modify properties on a domain object within an event listener. For example, you may wish to encode a user’s password prior to saving to the database, or check the value of a string property against a blacklist of forbidden words/characters. GORM events provide an entityAccess object which allows you to make get and set properties on the entity that triggered the event.

We’ll see how we can use entityAccess in our next GORM event listener.

We need to generate and assign a serial number to instances of our Book domain class. For this sample, the serial number will simply be a random, all-caps string, prefixed by the first few characters of the book’s title. For example, a book with a title of Groovy in Action might have a serial number of GROO-WKVLEQEDK.

Create a new Grails service, called SerialNumberGeneratorService:

~ grails create-service demo.SerialNumberGeneratorService
grails-app/services/demo/SerialNumberGeneratorService.groovy
package demo

import groovy.transform.CompileStatic
import org.apache.commons.lang.RandomStringUtils

@CompileStatic
class SerialNumberGeneratorService {

    String generate(String bookTitle) {
        String randomString = RandomStringUtils.random(8, true, false)
        String titleChars = "${bookTitle}".take(4) (1)
        "${titleChars}-${randomString}".toUpperCase()
    }
}
1 We use the take method to safely grab the first 4 characters from the title (take will not throw an IndexOutOfBoundsException if the string length is shorter than the desired range)

Moreover, we want to generate friendly, human-readable urls for a our book titles. Instead of using urls such as http://localhost:8080/book/show/1, we want to use http://localhost:8080/book/practical-grails-3

Create a new Grails service, called FriendlyUrlService:

~ grails create-service demo.FriendlyUrlService
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[-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}+", "")
    }
}

Edit the generated unit test spec for our FriendlyUrlService as shown below:

src/test/groovy/demo/FriendlyUrlServiceSpec.groovy
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 we will create a listener to handle new and updated book titles. Create a Grails service named TitleListenerService

~ grails create-service demo.TitleListenerService

We want to populate the serialNumber synchronously: whenever we insert a new Book instance. To capture GORM events synchronously, we can use the @Listener annotation instead of @Subscriber.

grails-app/services/demo/TitleListenerService.groovy
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 The @Listener annotation will transform this method into a GORM event listener. When GORM fires a persistence event, any methods marked @Listener (and with the appropriate method signature) will be called. The annotation takes a value argument, which can be either a single domain class or a list of domain classes for which to "listen" - in this case, only events fired for Book instances will trigger this method.
2 The onBookPreUpdate listener checks whether the title the book is "dirty" (meaning the property has been changed). If so, update the friendlyUrl property.
3 The isDirty method allows us to check for properties that have been changed on the persisted object.
4 We retrieve the title property from the domain object using the event.entityAccess.getProperty() method.
5 To set the serialNumber property on the domain object, we use the event.entityAccess.setProperty() method.
You may wonder why couldn’t simply cast the event.entityObject as a Book and then set the serialNumber property directly. The reason for avoiding that approach here is that the direct assignment would itself trigger another event, potentially causing the same event listener to fire multiple times. By using the entityAccess object, any changes we make will be synchronized with the current persistence session, and will be saved at the same time as the original object.

Again, the use of GORM data services eases unit testing. In the next test we Stub the GORM Data Service and we verify the serial number is populated.

src/test/groovy/demo/TitleListenerServiceSpec.groovy
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 To provide or replace beans in the context, you can override the doWithSpring method in your test.

Integration Testing

Next, let’s create an integration test to verify that the friendlyUrl property gets refreshed when title property is updated.

src/integration-test/groovy/demo/TitleListenerServiceIntegrationSpec.groovy
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

Arguably, a test for correct handling of events should be an integration test as shown previously. However you can create an equivalent Unit Test. We can can wire together just the parts of the application that we need to verify that our listeners are being called when we expect, without the expense of a full-fledged integration test (which would require the entire application to start up in order to execute the test).

Create the file TitleListenerServiceGrailsUnitSpec under src/test/groovy/demo, and edit the contents as shown below:

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 we will be wiring up GORM, the Spring context and the event system ourselves, we will simply implement the basic GrailsUnitTest trait rather than the more specific ServiceUnitTest trait.
2 We declare a Shared and AutoCleanup property for our datastore. In the setupSpec method, we obtain the HibernateDatastore instance contained within the applicationContext (provided by the GrailsUnitTest trait). We also set the transactionManager shared property we defined in our spec.
3 In our overridden doWithSpring method, we create the Spring beans for our friendlyUrlService, serialNumberGeneratorService, titleListenerService as well as our dataStore, instantiating the latter with a list of domain classes we need for our test. This will configure our service and GORM datastore in the Spring context of our test.
4 Our test is now trivial and almost identical as the previous integration test.

4 Running the Tests

To run the tests:

./grailsw
grails> test-app
grails> open test-report

or

./gradlew check
open build/reports/tests/index.html

5 Conclusion

Event listeners are a powerful concept, and GORM’s implementation will allow you to write intelligent business logic around persistence events without "cluttering" your domain classes. In addition, the improved testing support in the latest Grails releases make it easier than ever to write simple, thorough tests for your event listeners. Happy coding!

6 Help with Grails

Object Computing, Inc. (OCI) sponsored the creation of this Guide. A variety of consulting and support services are available.

OCI is Home to Grails

Meet the Team