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/andcomplete/ -
Docker — required for integration tests (Testcontainers PostgreSQL)
-
PostgreSQL on
localhost:5432— required for./gradlew bootRunincomplete/(default databasedevDb; seegrails-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:
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:
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
}
}
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:
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.
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:
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:
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:
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:
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:
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']
}
}
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:
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
wherequeries 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.
-
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.