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
BookandAuthordomain pair, withBookbelongsTo: Authorand reasonable constraints (a regex-validated ISBN, a positivepageCount, optionaldateOfBirth). -
BookController extends RestfulController<Book>andAuthorController extends RestfulController<Author>, each scoped to JSON viaresponseFormatsand bounded by alistAllResourcesoverride that hard-capsparams.maxat 100. -
JSON views (
.gson) undergrails-app/views/book/andgrails-app/views/author/that render the API responses. Each view emits a body, embeds a HAL-style_linksobject, and reuses a shared_book.gson/_author.gsonpartial. -
A
POST /v1/books/bulkendpoint 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 theAccept: application/vnd.example.v2+jsoncontent-negotiation approach and recommends starting with URL prefixes. -
Sample data in
BootStrapsocurl http://localhost:8080/v1/booksreturns 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
-
curlor 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.
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 }
}
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: 255on Author andtitleon 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 withunique: true. -
pageCount nullable: true, min: 1allows 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:
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.PRODUCTIONearly-exit means the seed never runs against a real database. -
The
Author.count() > 0check 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:
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 |
|
|
POST |
|
|
GET |
|
|
PUT |
|
|
PATCH |
|
|
DELETE |
|
|
GET |
|
|
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:
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:
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:
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
modelblock declares both the page (Iterable<Book> bookList) and the total count (Long bookCount).RestfulController.indexpopulates 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.gsonpartial 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:
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:
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 optionalorder=ascororder=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
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.
-
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.