Show Navigation

A Library REST API with Grails 8

Build a Book + Author REST API on Grails 8 using RestfulController, JSON views, structured 422 validation responses, bounded pagination, all-or-nothing bulk create, and URL-prefix versioning under /v1/.

Authors: James Fredley

Grails Version: 8

1 Getting Started

In this guide you will build a small Library REST API on Apache Grails 8 with two domain classes (Book and Author), a RestfulController-driven CRUD surface, JSON views (.gson), and the production-quality concerns most beginner REST guides skip: structured 422 validation responses, bounded pagination, URL-prefix versioning, and an all-or-nothing bulk-create endpoint.

This guide targets Apache Grails 8 / Spring Boot 4 / JDK 21.

1.1 What You Will Build

By the end of the guide you will have:

  • A Book and Author domain pair, with Book belongsTo: Author and reasonable constraints (a regex-validated ISBN, a positive pageCount, optional dateOfBirth).

  • BookController extends RestfulController<Book> and AuthorController extends RestfulController<Author>, each scoped to JSON via responseFormats and bounded by a listAllResources override that hard-caps params.max at 100.

  • JSON views (.gson) under grails-app/views/book/ and grails-app/views/author/ that render the API responses. Each view emits a body, embeds a HAL-style _links object, and reuses a shared _book.gson / _author.gson partial.

  • A POST /v1/books/bulk endpoint that accepts a JSON array, validates every entry, and returns a structured 422 with the field errors of every failing entry on the first validation failure (the whole transaction rolls back).

  • URL-prefix versioning: every endpoint lives under /v1/. The chapter on versioning compares this with the Accept: application/vnd.example.v2+json content-negotiation approach and recommends starting with URL prefixes.

  • Sample data in BootStrap so curl http://localhost:8080/v1/books returns four real books from two real authors immediately after ./gradlew bootRun.

1.2 What You Will Need

To complete this guide you will need:

  • JDK 21

  • About 30 minutes

  • curl or another HTTP client for hitting the running endpoints

1.3 How to Complete the Guide

You can either type the code in this guide as you read or skip ahead and clone the finished sample:

git clone -b grails8 https://github.com/grails-guides/grails-rest-library.git
cd grails-rest-library/complete
./gradlew bootRun
curl -s http://localhost:8080/v1/books | jq

initial/ is the vanilla Grails 8 rest_api starter from start.grails.org (with the postgres, testcontainers, views-json, and database-migration features). complete/ adds the Book + Author domain model, the controllers, the JSON views, and the bootstrap data.

2 Creating the Application

Generate a fresh Apache Grails 8 Rest API application from start.grails.org. Pick the rest_api application type (not web); it ships JSON views by default and skips the GSP view layer the regular web profile drags along.

2.1 Download a Grails 8 Rest API Starter

Open start.grails.org, pick the rest_api profile, name the application library, set the package to example, choose JDK 21, tick the postgres, testcontainers, views-json, and database-migration features, and download the generated zip. Unzip it and cd into the library directory.

The rest_api starter is leaner than the web starter: no asset pipeline, no GSP, no Bootstrap webjars. The views-json feature adds the .gson view support and the matching org.apache.grails:grails-views-gson dependency.

3 The Book and Author Domain Model

Two domain classes model the library. Author is independent; Book belongs to an Author.

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

import grails.persistence.Entity

@Entity
class Author {

    String name
    String biography
    Date dateOfBirth

    static hasMany = [books: Book]

    static constraints = {
        name        blank: false, maxSize: 255
        biography   nullable: true, maxSize: 4000
        dateOfBirth nullable: true
    }

    static mapping = {
        books fetch: 'lazy'
    }

    String toString() { name }
}
grails-app/domain/example/Book.groovy
package example

import grails.persistence.Entity

@Entity
class Book {

    String  title
    String  isbn
    Integer pageCount
    Date    publishedOn

    static belongsTo = [author: Author]

    static constraints = {
        title       blank: false, maxSize: 255
        isbn        blank: false, unique: true, maxSize: 20, matches: /^(97(8|9))?\d{9}(\d|X)$/
        pageCount   nullable: true, min: 1
        publishedOn nullable: true
    }

    String toString() { title }
}

A few constraint choices to highlight:

  • name blank: false, maxSize: 255 on Author and title on Book reject empty strings and cap database column width, so the rejection is surfaced at validation time, not as a SQL error.

  • isbn matches: /^(97(8|9))?\d{9}(\d|X)$/ is a soft ISBN-10/13 check. It is intentionally permissive (no checksum validation); the regex is a chapter-and-verse example of how Grails' matches: constraint composes with unique: true.

  • pageCount nullable: true, min: 1 allows an unset value but rejects zero or negative integers.

The static mapping block on Author lazy-loads books. With eager loading, every Author.list() call would fetch every book of every author - the textbook N+1 problem. Lazy loading delegates to GORM’s session lifecycle, and views explicitly opt back in to eager fetch via Author.list(fetch: [books: 'join']) when they need it.

3.1 Bootstrap Sample Data

A BootStrap class seeds two authors and four books on first startup, but only outside production:

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

import grails.util.Environment
import org.slf4j.Logger
import org.slf4j.LoggerFactory

class BootStrap {

    private static final Logger log = LoggerFactory.getLogger(BootStrap)

    Closure init = { servletContext ->

        if (Environment.current == Environment.PRODUCTION) {
            log.info 'production environment - skipping bootstrap data'
            return
        }
        if (Author.count() > 0) {
            log.info 'authors already present - skipping bootstrap data'
            return
        }

        Author.withTransaction {
            Author tolkien = new Author(name: 'J.R.R. Tolkien',
                                        biography: 'English writer and philologist (1892-1973).',
                                        dateOfBirth: Date.parse('yyyy-MM-dd', '1892-01-03'))
                                        .save(failOnError: true)
            Author leguin = new Author(name: 'Ursula K. Le Guin',
                                       biography: 'American author (1929-2018).',
                                       dateOfBirth: Date.parse('yyyy-MM-dd', '1929-10-21'))
                                       .save(failOnError: true)

            new Book(author: tolkien, title: 'The Hobbit', isbn: '9780547928227', pageCount: 310,
                     publishedOn: Date.parse('yyyy-MM-dd', '1937-09-21')).save(failOnError: true)
            new Book(author: tolkien, title: 'The Fellowship of the Ring', isbn: '9780547928210', pageCount: 423,
                     publishedOn: Date.parse('yyyy-MM-dd', '1954-07-29')).save(failOnError: true)
            new Book(author: leguin, title: 'A Wizard of Earthsea', isbn: '9780547851402', pageCount: 205,
                     publishedOn: Date.parse('yyyy-MM-dd', '1968-11-01')).save(failOnError: true)
            new Book(author: leguin, title: 'The Left Hand of Darkness', isbn: '9780441478125', pageCount: 304,
                     publishedOn: Date.parse('yyyy-MM-dd', '1969-03-01')).save(failOnError: true)
        }
    }

    Closure destroy = { }
}

Two guards keep this safe:

  • Environment.current == Environment.PRODUCTION early-exit means the seed never runs against a real database.

  • The Author.count() > 0 check makes the seed idempotent during development - subsequent restarts do not create duplicate rows.

The @Transactional annotation on the init closure means the four save(failOnError: true) calls all commit together or all roll back. Without it, a failure on book 3 would leave books 1 and 2 in the database in a half-loaded state.

4 URL Mappings and v1 Versioning

URL-prefix versioning lives in UrlMappings.groovy:

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

class UrlMappings {

    static mappings = {

        '/v1/books'(resources: 'book') {
            collection {
                '/bulk'(controller: 'book', action: 'bulkCreate', method: 'POST')
            }
        }
        '/v1/authors'(resources: 'author')

        '/'(view: '/index')

        '500'(view: '/error')
        '404'(view: '/notFound')
    }
}

The '/v1/books'(resources: 'book') line generates the standard seven REST routes:

Method Path Controller action

GET

/v1/books

index

POST

/v1/books

save

GET

/v1/books/{id}

show

PUT

/v1/books/{id}

update

PATCH

/v1/books/{id}

patch

DELETE

/v1/books/{id}

delete

GET

/v1/books/create

create (rarely used in JSON APIs; safe to ignore)

The nested collection { …​ } block adds non-instance routes - in our case POST /v1/books/bulk mapped to a bulkCreate action we will write later.

5 RestfulController With a Hard Page Cap

AuthorController extends grails.rest.RestfulController<Author>, which provides every action the URL mappings reference (index, show, save, update, patch, delete) for free:

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

import grails.rest.RestfulController

class AuthorController extends RestfulController<Author> {

    static responseFormats = ['json']

    AuthorController() {
        super(Author)
    }

    @Override
    def index(Integer max) {
        if (max != null && max < 0) {
            max = null
        }

        params.max = Math.min(max ?: 25, 100)
        params.offset = Math.max(params.int('offset', 0), 0)

        respond listAllResources(params), model: [authorCount: countResources()]
    }
}

responseFormats = ['json'] restricts every action to negotiate JSON; the legacy XML-by-default behaviour from older Grails versions is gone.

The single override is listAllResources. Without it, a GET /v1/authors?max=99999 would happily return every row in the table. The hard cap (Math.min(params.int('max', 25), 100)) bounds the response, with 25 as the default when the client does not pass max. params.offset, params.sort, and params.order flow through unchanged so a paginating client gets ?max=25&offset=50&sort=name&order=asc semantics out of the box.

6 JSON Views

JSON views (.gson) shape the response without touching the controller. Each view declares its model and emits the JSON body using a Groovy DSL.

The shared partial:

grails-app/views/book/_book.gson
import example.Book

model {
    Book book
}

json {
    id          book.id
    title       book.title
    isbn        book.isbn
    pageCount   book.pageCount
    publishedOn book.publishedOn?.format('yyyy-MM-dd')
    author {
        id   book.author.id
        name book.author.name
    }
    _links {
        self {
            href g.link(resource: book, absolute: true)
        }
        author {
            href g.link(resource: book.author, absolute: true)
        }
    }
}

The model { } block declares the Groovy types the view expects. The json { } block emits the response body. The _links object is plain JSON; nothing about HAL or HATEOAS is enforced - it is a convention you can either keep or drop, but having it makes API discovery from a browser dramatically easier.

The list view reuses the partial via g.render:

grails-app/views/book/index.gson
import example.Book

model {
    Iterable<Book> bookList
    Integer        bookCount
}

json {
    page     Math.max(0, (params.int('offset', 0) ?: 0).intdiv(params.int('max', 25)))
    pageSize params.int('max', 25)
    total    bookCount
    items    g.render(template: 'book', collection: bookList ?: [], var: 'book')
}

Three things to notice:

  • The model block declares both the page (Iterable<Book> bookList) and the total count (Long bookCount). RestfulController.index populates both automatically.

  • The pagination metadata at the top (page, pageSize, total) gives clients enough information to render a pager without re-querying.

  • bookList.collect { Book b → g.render(template: 'book', model: [book: b]) } reuses the _book.gson partial for every item. One source of truth for the body shape; both the show and index views use it.

The show.gson view is a one-liner that delegates to the same partial:

grails-app/views/book/show.gson
import example.Book

model {
    Book book
}

json g.render(template: 'book', model: [book: book])

Author views follow the same pattern: _author.gson partial, index.gson list with pagination metadata, show.gson one-liner.

7 Structured 422 Validation Responses

When a POST /v1/books body fails validation, RestfulController returns a 422 by default - but the body shape is not always what API consumers expect. The hand-rolled bulkCreate action in BookController shows the structured-422 pattern:

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

import grails.gorm.transactions.Transactional
import grails.rest.RestfulController
import org.springframework.http.HttpStatus
import org.springframework.validation.FieldError

class BookController extends RestfulController<Book> {

    static responseFormats = ['json']

    BookController() {
        super(Book)
    }

    @Override
    def index(Integer max) {
        if (max != null && max < 0) {
            max = null
        }

        params.max = Math.min(max ?: 25, 100)
        params.offset = Math.max(params.int('offset', 0), 0)

        respond listAllResources(params), model: [bookCount: countResources()]
    }

    @Override
    protected List<Book> listAllResources(Map params) {
        Long authorId = params.long('author')
        Map queryParams = [max: params.max, offset: params.offset, sort: params.sort, order: params.order].findAll { it.value != null }

        if (authorId != null) {
            return Book.where {
                author.id == authorId
            }.list(queryParams)
        }

        Book.list(queryParams)
    }

    @Override
    protected Integer countResources() {
        Long authorId = params.long('author')
        if (authorId != null) {
            return Book.where {
                author.id == authorId
            }.count() as Integer
        }

        Book.count()
    }

    @Transactional
    def bulkCreate() {
        if (!(request.JSON instanceof List)) {
            response.status = HttpStatus.UNPROCESSABLE_ENTITY.value()
            respond([
                errors: [[code: 'invalid.body', message: 'Request body must be a JSON array of book objects']]
            ])
            return
        }

        List<Book> drafts = request.JSON.collect { Object json ->
            Book draft = new Book()
            bindData(draft, json as Map)
            draft
        }

        List<Map<String, Object>> failures = []
        drafts.eachWithIndex { Book draft, int index ->
            draft.validate()
            if (draft.hasErrors()) {
                failures << [
                    index : index,
                    errors: draft.errors.allErrors.collect { error ->
                        Map<String, Object> payload = [
                            object : error.objectName,
                            code   : error.code,
                            message: message(error: error)
                        ]
                        if (error instanceof FieldError) {
                            payload.field = error.field
                            payload.rejectedValue = error.rejectedValue
                            payload.bindingFailure = error.bindingFailure
                        }
                        payload
                    }
                ]
            }
        }

        if (failures) {
            transactionStatus.setRollbackOnly()
            response.status = HttpStatus.UNPROCESSABLE_ENTITY.value()
            respond([books: failures])
            return
        }

        drafts.each { Book book ->
            book.save(flush: true, failOnError: true)
        }

        params.max = Math.max(drafts.size(), 1)
        params.offset = 0
        respond drafts, [status: HttpStatus.CREATED, view: 'index', model: [bookCount: drafts.size()]]
    }
}

When validation fails, the body looks like:

{
  "books": [
    {
      "index": 0,
      "errors": [
        { "field": "title", "code": "blank",   "message": "Property [title] of class [Book] cannot be blank" },
        { "field": "isbn",  "code": "matches", "message": "Property [isbn] of class [Book] does not match the required pattern" }
      ]
    },
    {
      "index": 2,
      "errors": [
        { "field": "isbn", "code": "unique", "message": "Property [isbn] of class [Book] with value [9780547928227] must be unique" }
      ]
    }
  ]
}

The shape is opinionated: each failing object has its source-array index so the client can map the failure back to the input, and each error has a stable code (blank, matches, unique, nullable, min, max) the client can branch on without parsing free-text messages. Any client building a form on top of this API can light up the right field with the right error without string-matching.

The transactionStatus.setRollbackOnly() line is what makes bulkCreate all-or-nothing: even though the action’s @Transactional started a transaction implicitly, this call marks it for rollback regardless of what the action returns. The four books that did validate never reach the database.

8 Pagination, Sorting, and Filtering

Every list endpoint in this guide exposes the same three query parameters:

  • max - page size, default 25, hard-capped at 100.

  • offset - zero-based starting offset, default 0.

  • sort - column name to sort by, with optional order=asc or order=desc.

RestfulController.index reads params.max and params.offset automatically; the override on listAllResources we already wrote bounds them.

Round-trip with curl:

curl -s 'http://localhost:8080/v1/books?max=2&offset=2&sort=publishedOn&order=desc' | jq

Returns:

{
  "page": 1,
  "pageSize": 2,
  "total": 4,
  "items": [
    { "id": 2, "title": "The Fellowship of the Ring", ... },
    { "id": 1, "title": "The Hobbit", ... }
  ]
}

The page value is offset / pageSize; total is the unpaginated row count. A client that wants a page-and-size API instead of an offset-and-size API can compute either from the other.

Filtering sits orthogonal to pagination. If you want GET /v1/books?author=2, override listAllResources to consume params.author:

@Override
protected List<Book> listAllResources(Map params) {
    params.max = Math.min(params.int('max', 25), 100)
    if (params.author) {
        Book.findAllByAuthor(Author.get(params.long('author')), params)
    } else {
        Book.list(params)
    }
}

This stays out of the URL mappings layer; the same route serves both filtered and unfiltered queries.

9 All-or-Nothing Bulk Create

A frequent ask from API consumers is "let me create N records in one round trip". The single POST /v1/books/bulk endpoint added in the URL mappings handles it:

curl -s -X POST http://localhost:8080/v1/books/bulk \
  -H 'Content-Type: application/json' \
  -d '[
    { "title": "The Two Towers", "isbn": "9780547928203", "pageCount": 416, "publishedOn": "1954-11-11", "author": { "id": 1 } },
    { "title": "The Return of the King", "isbn": "9780547928197", "pageCount": 432, "publishedOn": "1955-10-20", "author": { "id": 1 } }
  ]'

The bulkCreate action validates every entry first, then saves them. If any single book fails validation, the whole transaction rolls back via transactionStatus.setRollbackOnly() and the response is a 422 with structured field errors for every failing entry. If all entries validate, the response is a 201 with the persisted bodies.

This is the safest semantic for bulk creates in a typed API: clients never have to deal with "of the ten you sent, six made it through and four did not". They get either all ten or none, with a precise diagnosis on failure.

10 URL-Prefix vs Content-Negotiation Versioning

Two patterns dominate REST versioning:

  • URL prefix: /v1/books, /v2/books. Cheap to implement, trivially debuggable, plays well with caches and proxies.

  • Content negotiation: Accept: application/vnd.example.v2+json. Cleaner semantically (the URL is the resource, not the representation), but adds friction to every consumer.

This guide leads with URL prefix because it is the cheaper choice for a project that has not yet shipped its first breaking change. The '/v1/books'(resources: 'book') URL mapping localises every v1 route under a single prefix; when v2 ships, you add a second '/v2/books'(resources: 'bookV2') mapping to a fresh BookV2Controller and let v1 keep serving its existing clients while you migrate them.

The migration discipline that does matter:

  • Never break v1 within its lifetime. Even adding a new required field is a breaking change for a strict client. Add fields as optional; deprecate via response headers.

  • Document a deprecation timeline on day 1 of v2. "v1 sunsets 12 months after v2 GA" gives consumers a window.

  • Run v1 and v2 from the same codebase whenever you can. Two controllers, two view trees, one domain model. Forking the domain model is a smell.

For the small percentage of cases where content negotiation makes sense - public APIs with thousands of consumers, where /v1 in URLs would be visually offensive - Grails' responseFormats and the Accept-header mapping are first-class. The mechanics are the same; only the surface changes.

11 Running the Application

With everything in place, run the app:

./gradlew bootRun

The bootstrap data populates two authors and four books on first start. Smoke-test the surface:

# List
curl -s http://localhost:8080/v1/books | jq
curl -s http://localhost:8080/v1/authors | jq

# Show single
curl -s http://localhost:8080/v1/books/1 | jq

# Pagination
curl -s 'http://localhost:8080/v1/books?max=2&offset=2&sort=title' | jq

# Create
curl -s -X POST http://localhost:8080/v1/books \
  -H 'Content-Type: application/json' \
  -d '{ "title": "The Silmarillion", "isbn": "9780544338012", "pageCount": 384, "author": { "id": 1 } }' | jq

# Validation failure (422 with structured field errors)
curl -s -X POST http://localhost:8080/v1/books \
  -H 'Content-Type: application/json' \
  -d '{ "title": "" }' | jq

# Bulk create (all-or-nothing)
curl -s -X POST http://localhost:8080/v1/books/bulk \
  -H 'Content-Type: application/json' \
  -d '[
    { "title": "The Two Towers", "isbn": "9780547928203", "pageCount": 416, "author": { "id": 1 } }
  ]' | jq

Each of these returns a sensible JSON body with the right HTTP status. The validation-failure curl returns 422 with the structured field errors from the validation chapter; the rest return 200 (list/show), 201 (create), or 204 (delete) as appropriate.

12 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.