Show Navigation

Data Access with GORM in Grails 8

Model relational data with GORM, run criteria/HQL/where queries, expose REST endpoints, and test with Spock and Testcontainers.

Authors: Sanjana

Grails Version: 8

1 Introduction

Learn how to model relational data with GORM, express associations, run criteria/HQL/where queries, expose REST endpoints, and bulletproof the stack with Spock unit and integration tests.

This guide follows the standard Grails guides layout: 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 Grails installation or the bundled Gradle wrapper in initial/ and complete/

  • Docker — required for integration tests (Testcontainers PostgreSQL)

  • PostgreSQL on localhost:5432 — required for ./gradlew bootRun in complete/ (default database devDb; see grails-app/conf/application.yml)

2 Getting Started

Clone the repository and run the starting application:

git clone -b grails8 https://github.com/grails-guides/grails-data-access.git
cd grails-data-access/initial
./gradlew bootRun

Open http://localhost:8080/ for the welcome JSON payload.

To skip ahead, cd complete and run the same commands — that tree contains the finished domain model, services, and tests.

Verify tests

./gradlew test

Both initial and complete must pass in CI (see .github/workflows/grails8.yml).

3 Domain model and associations

We model a small catalog: authors write books, and books carry many tags (many-to-many).

Author

Create grails-app/domain/example/Author.groovy:

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

import grails.persistence.Entity

@Entity
class Author {

    String name
    String email

    static hasMany = [books: Book]

    static constraints = {
        name blank: false, maxSize: 100
        email email: true, unique: true, nullable: true, maxSize: 255
    }

    String toString() {
        name
    }
}

Book and Tag

Book belongs to Author and links to many Tag instances through a join table:

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

import grails.persistence.Entity

import java.time.LocalDate

@Entity
class Book {

    String title
    String isbn
    BigDecimal price
    LocalDate publishedOn
    Author author

    static belongsTo = [author: Author]

    static hasMany = [tags: Tag]

    static mapping = {
        tags joinTable: [name: 'book_tag', key: 'book_id', column: 'tag_id']
    }

    static constraints = {
        title blank: false, maxSize: 255
        isbn nullable: true, maxSize: 20
        price nullable: false, min: 0.01G, scale: 2
        publishedOn nullable: true
        author nullable: false
    }

    String toString() {
        title
    }
}
grails-app/domain/example/Tag.groovy
package example

import grails.persistence.Entity

@Entity
class Tag {

    String name

    static hasMany = [books: Book]

    static constraints = {
        name blank: false, unique: true, maxSize: 50
    }

    String toString() {
        name
    }
}

Seed data in BootStrap (development only) demonstrates cascading associations:

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

import java.time.LocalDate

class BootStrap {

    def init = { servletContext ->
        environments {
            development {
                Author.withTransaction {
                    if (Author.count() == 0) {
                        def tags = ['gorm', 'grails', 'data-access'].collect { new Tag(name: it).save(failOnError: true) }

                        def author = new Author(name: 'Ada Lovelace', email: 'ada@example.com').save(failOnError: true)

                        def book = new Book(
                            title: 'Computing and Composition',
                            isbn: '978-0000000001',
                            price: 24.99,
                            publishedOn: LocalDate.of(1843, 10, 1),
                            author: author
                        ).save(failOnError: true)

                        tags.each { book.addToTags(it) }
                        book.save(failOnError: true, flush: true)
                    }
                }
            }
        }
    }

    def destroy = {
    }
}

4 Querying with GORM

Encapsulate query logic in services so controllers stay thin.

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

import grails.gorm.transactions.Transactional
import java.time.LocalDate

@Transactional
class BookService {

    List<Book> listByAuthor(Author author, Integer max = 20) {
        Book.createCriteria().list(max: max) {
            eq 'author', author
            order 'title', 'asc'
        } as List<Book>
    }

    List<Book> findPublishedSince(LocalDate since) {
        if (!since) {
            return []
        }
        Book.where {
            publishedOn >= since
        }.list(sort: 'publishedOn', order: 'desc')
    }

    List<Book> searchByTitle(String term) {
        if (!term?.trim()) {
            return []
        }
        String pattern = "%${term.trim()}%"
        Book.createCriteria().list {
            ilike 'title', pattern
            order 'title'
        } as List<Book>
    }

    Book saveBook(Book book) {
        book.save(failOnError: true, flush: true)
    }
}

Additional read-only queries live in BookQueryService:

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

import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional

@Transactional
class BookQueryService {

    /**
     * Criteria-style query: books for an author by name (case-insensitive contains).
     */
    @ReadOnly
    List<Book> findBooksByAuthorName(String authorName) {
        if (!authorName?.trim()) {
            return []
        }
        String pattern = "%${authorName.trim()}%"
        Book.createCriteria().list {
            author {
                ilike('name', pattern)
            }
            order('title')
        } as List<Book>
    }

    /**
     * HQL aggregate: how many books an author has published.
     */
    @ReadOnly
    long countBooksForAuthor(Long authorId) {
        if (!authorId) {
            return 0L
        }
        (Book.executeQuery(
            'select count(b) from Book b where b.author.id = :authorId',
            [authorId: authorId]
        )[0] as Long) ?: 0L
    }

    /**
     * Criteria-style query: books at or above a minimum price.
     */
    @ReadOnly
    List<Book> findBooksWithMinPrice(BigDecimal minimumPrice) {
        BigDecimal min = minimumPrice ?: BigDecimal.ZERO
        Book.where {
            price >= min
        }.list(sort: 'price', order: 'desc')
    }
}

Criteria

listByAuthor uses the criteria DSL to filter and order results.

HQL

BookQueryService.countBooksForAuthor runs an HQL aggregate with a named parameter:

Long count = Book.executeQuery(
    'select count(b) from Book b where b.author.id = :authorId',
    [authorId: authorId]
)[0] as Long

Criteria and where queries cover the other examples in this guide.

Where queries

findPublishedSince uses type-safe where queries for date filters. searchByTitle uses criteria ilike for case-insensitive title search (consistent with findBooksByAuthorName).

5 Services and REST controllers

REST resources are mapped under /api:

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

class UrlMappings {

    static mappings = {
        "/$controller/$action?/$id?(.$format)?"{
            constraints {
                // apply constraints here
            }
        }

        "/api/books/byAuthor/$authorId"(controller: 'book', action: 'byAuthor')
        "/api/books/search"(controller: 'book', action: 'search')
        "/api/books"(resources: 'book')
        "/api/authors"(resources: 'author')

        "/"(controller: 'application', action: 'index')
        "500"(view: '/error')
        "404"(view: '/notFound')
    }
}

BookController delegates persistence and queries to BookService:

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

import grails.converters.JSON
import grails.gorm.transactions.Transactional

class BookController {

    static responseFormats = ['json']
    static allowedMethods = [index: 'GET', show: 'GET', save: 'POST', update: 'PUT', delete: 'DELETE']

    BookService bookService

    def index(Integer max) {
        params.max = Math.min(max ?: 10, 100)
        respond Book.list(params), model: [bookCount: Book.count()]
    }

    def show(Long id) {
        respond Book.get(id)
    }

    @Transactional
    def save() {
        def book = new Book(request.JSON as Map)
        if (!book.validate()) {
            respond book.errors, status: 422
            return
        }
        bookService.saveBook(book)
        respond book, status: 201
    }

    @Transactional
    def update(Long id) {
        def book = Book.get(id)
        if (!book) {
            render status: 404
            return
        }
        book.properties = request.JSON
        if (!book.validate()) {
            respond book.errors, status: 422
            return
        }
        bookService.saveBook(book)
        respond book
    }

    @Transactional
    def delete(Long id) {
        def book = Book.get(id)
        if (!book) {
            render status: 404
            return
        }
        book.delete(flush: true)
        render status: 204
    }

    def byAuthor(Long authorId) {
        def author = Author.get(authorId)
        if (!author) {
            render status: 404
            return
        }
        respond bookService.listByAuthor(author)
    }

    def search(String q) {
        respond bookService.searchByTitle(q)
    }
}

Call validate() after binding request JSON and before save. That returns structured 422 responses for constraint violations; relying on hasErrors() alone only catches binding errors, and save(failOnError: true) would otherwise surface ValidationException as 500.

JSON responses are rendered with GSON views (for example _book.gson).

6 Testing data access

Unit tests

Domain constraints and associations:

src/test/groovy/example/BookSpec.groovy
package example

import grails.testing.gorm.DataTest
import spock.lang.Specification

import java.time.LocalDate

class BookSpec extends Specification implements DataTest {

    Class<?>[] getDomainClassesToMock() {
        [Author, Book, Tag] as Class[]
    }

    void 'book requires an author'() {
        def book = new Book(title: 'Untitled')

        when:
        boolean valid = book.validate()

        then:
        !valid
        book.errors['author']
    }

    void 'many-to-many tags can be associated'() {
        given:
        def author = new Author(name: 'Test Author').save(flush: true)
        def tag = new Tag(name: 'gorm').save(flush: true)
        def book = new Book(title: 'GORM in Action', price: 39.99, author: author, publishedOn: LocalDate.now()).save(flush: true)

        when:
        book.addToTags(tag)
        book.save(flush: true)

        then:
        Book.get(book.id).tags*.name == ['gorm']
    }
}

Service-level criteria, HQL, and where queries with DataTest:

src/test/groovy/example/BookServiceSpec.groovy
package example

import grails.testing.gorm.DataTest
import grails.testing.services.ServiceUnitTest
import spock.lang.Specification

import java.time.LocalDate

class BookServiceSpec extends Specification implements ServiceUnitTest<BookService>, DataTest {

    Class<?>[] getDomainClassesToMock() {
        [Author, Book, Tag] as Class[]
    }

    void 'listByAuthor uses criteria'() {
        given:
        def ada = new Author(name: 'Ada').save(flush: true)
        def other = new Author(name: 'Other').save(flush: true)
        new Book(title: 'A', price: 10.00, author: ada).save(flush: true)
        new Book(title: 'B', price: 12.00, author: other).save(flush: true)

        expect:
        service.listByAuthor(ada)*.title == ['A']
    }

    void 'findPublishedSince uses where query'() {
        given:
        def author = new Author(name: 'Ada').save(flush: true)
        new Book(title: 'Old', price: 9.99, author: author, publishedOn: LocalDate.of(2000, 1, 1)).save(flush: true)
        new Book(title: 'New', price: 19.99, author: author, publishedOn: LocalDate.of(2024, 6, 1)).save(flush: true)

        expect:
        service.findPublishedSince(LocalDate.of(2020, 1, 1))*.title == ['New']
    }

    void 'searchByTitle uses where query'() {
        given:
        def author = new Author(name: 'Ada').save(flush: true)
        new Book(title: 'Grails Data Access', price: 29.99, author: author).save(flush: true)
        new Book(title: 'Other Topic', price: 14.99, author: author).save(flush: true)

        expect:
        service.searchByTitle('data')*.title == ['Grails Data Access']
    }
}
src/test/groovy/example/BookQueryServiceSpec.groovy
package example

import grails.testing.gorm.DataTest
import grails.testing.services.ServiceUnitTest
import spock.lang.Specification

class BookQueryServiceSpec extends Specification implements ServiceUnitTest<BookQueryService>, DataTest {

    Class[] getDomainClassesToMock() {
        [Author, Book, Tag] as Class[]
    }

    void "findBooksByAuthorName matches case-insensitively"() {
        given:
        Author author = new Author(name: 'Jane Austen', email: 'jane@example.com').save(flush: true, failOnError: true)
        new Book(title: 'Emma', price: 11.50, author: author).save(flush: true, failOnError: true)
        new Book(title: 'Other', price: 5.00, author: new Author(name: 'Other', email: 'o@example.com').save(flush: true)).save(flush: true)

        when:
        List<Book> books = service.findBooksByAuthorName('jane')

        then:
        books*.title == ['Emma']
    }

    void "findBooksWithMinPrice filters and sorts by price descending"() {
        given:
        Author author = new Author(name: 'A', email: 'a@example.com').save(flush: true, failOnError: true)
        new Book(title: 'Low', price: 5.00, author: author).save(flush: true)
        new Book(title: 'High', price: 25.00, author: author).save(flush: true)

        when:
        List<Book> books = service.findBooksWithMinPrice(10.00)

        then:
        books*.title == ['High']
    }
}

HQL (countBooksForAuthor) is exercised in the integration spec below because executeQuery requires a Hibernate session.

Integration tests

Integration specs boot the full application context and hit the database:

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

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

import java.time.LocalDate

@Integration
class BookDataAccessIntegrationSpec extends Specification {

    BookService bookService
    BookQueryService bookQueryService

    void "criteria, HQL, and where queries run against a real database"() {
        given:
        Author author = Author.withTransaction {
            def existingAuthor = new Author(name: 'Integration Author', email: 'integration@example.com').save(flush: true, failOnError: true)
            new Book(
                title: 'Persistence Patterns',
                price: 29.99,
                author: existingAuthor,
                publishedOn: LocalDate.of(2019, 1, 1)
            ).save(flush: true, failOnError: true)
            new Book(
                title: 'GORM Deep Dive',
                price: 34.50,
                author: existingAuthor,
                publishedOn: LocalDate.of(2024, 6, 1)
            ).save(flush: true, failOnError: true)
            new Book(
                title: 'Grails Data Access',
                price: 19.99,
                author: existingAuthor,
                publishedOn: LocalDate.of(2024, 1, 1)
            ).save(flush: true, failOnError: true)
            existingAuthor
        }

        expect:
        bookQueryService.findBooksByAuthorName('integration').size() == 3
        bookQueryService.countBooksForAuthor(author.id) == 3L
        bookQueryService.findBooksWithMinPrice(30.00)*.title == ['GORM Deep Dive']
        bookService.listByAuthor(author)*.title.sort() == ['GORM Deep Dive', 'Grails Data Access', 'Persistence Patterns']
        bookService.findPublishedSince(LocalDate.of(2020, 1, 1))*.title == ['GORM Deep Dive', 'Grails Data Access']
        bookService.searchByTitle('data')*.title == ['Grails Data Access']
    }
}

Run unit tests:

cd complete
./gradlew test

Run integration tests (requires Docker):

./gradlew integrationTest

7 Running the application

cd complete
./gradlew bootRun

Example requests:

  • GET /api/authors

  • GET /api/books

  • GET /api/books/search?q=data

  • GET /api/books/byAuthor/1

8 Summary

You created a REST API backed by GORM with:

  • belongsTo, hasMany, and many-to-many associations

  • Criteria, HQL, and where queries in a service layer

  • Spock unit and integration tests

Next steps: enable the database-migration plugin, add validation groups, or extend the API with pagination and filtering.

9 Do you need help with Grails?

Help with Apache Grails

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

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