grails-developer

SkillDev tools

Lets your agent get expert guidance on building Grails web apps, REST APIs, GORM data models, and Spock or Geb tests.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the grails-developer skill

About this capability

Comprehensive guide for current Grails development, covering web applications, REST APIs, GORM, controllers, services, views, plugins, and testing with Spock and Geb

What this skill tells your AI

The instructions your AI receives, as published by apache/grails-core in .agents/skills/grails-developer/SKILL.md and read by ahel’s review.

What I Do

  • Provide detailed guidance for building current Grails web applications and REST APIs.
  • Assist with GORM for data modeling, controllers for request handling, services for business logic, and views (GSP, JSON, Markup).
  • Support testing with Spock 2.4 (unit/integration tests) and Geb for browser automation.
  • Guide plugin usage and development, security implementation, and deployment strategies.
  • Help with configuration, internationalization, async programming, and performance optimization.

When to Use Me

Activate this skill when developing with current Grails, including:

  • Building CRUD applications, RESTful APIs, or full-stack web applications.
  • Working with GORM domain classes, constraints, and relationships.
  • Creating controllers, services, interceptors, or tag libraries.
  • Writing Spock specifications or Geb functional tests.
  • Configuring plugins, security, caching, or database migrations.
  • Deploying Grails applications to containers or cloud platforms.

Technology Stack

Current Grails is built on:

  • Spring Boot: 4.1.x
  • Spring Framework: 7.0.x
  • Groovy: 5.0.x
  • Gradle: 9.6.x
  • Spock: 2.4-groovy-5.0
  • Jakarta EE: 10 (migrated from javax.*)
  • Micronaut: Optional via grails-micronaut plugin

Project Structure

myapp/
├── grails-app/
│   ├── conf/                 # Configuration
│   │   ├── application.yml   # Main config
│   │   ├── logback-spring.xml # Logging config
│   │   └── spring/resources.groovy # Spring bean definitions
│   ├── controllers/          # Request handlers
│   ├── domain/               # GORM domain classes
│   ├── i18n/                 # Message bundles
│   ├── init/                 # Bootstrap classes
│   ├── services/             # Business logic
│   ├── taglib/               # Custom GSP tags
│   ├── utils/                # Utility classes
│   └── views/                # GSP templates
├── src/
│   ├── main/groovy/          # Additional Groovy classes
│   ├── main/java/            # Java classes
│   ├── test/groovy/          # Test specifications
│   └── integration-test/groovy/ # Integration test specifications
├── build.gradle              # Build configuration
└── gradle.properties         # Project properties

Domain Classes (GORM)

Basic Domain Class

class Book {
    String title
    String author
    Date datePublished
    Integer pages

    static constraints = {
        title blank: false, maxSize: 255
        author blank: false
        datePublished nullable: true
        pages min: 1, nullable: true
    }

    static mapping = {
        table 'books'
        title column: 'book_title'
        datePublished type: 'date'
    }
}

Relationships

class Author {
    String name

    static hasMany = [books: Book]

    static mapping = {
        books cascade: 'all-delete-orphan'
    }
}

class Book {
    String title

    static belongsTo = [author: Author]
}

// Many-to-Many
class Book {
    static hasMany = [categories: Category]
    static belongsTo = Category
}

class Category {
    String name
    static hasMany = [books: Book]
}

GORM Queries

// Dynamic finders
Book.findByTitle("Grails Guide")
Book.findAllByAuthorLike("%Smith%")
Book.findByTitleAndAuthor("Guide", "Smith")
Book.countByPagesGreaterThan(100)

// Where queries
Book.where {
    title =~ "%Grails%" && pages > 100
}.list()

// Criteria queries
Book.withCriteria {
    like('title', '%Grails%')
    gt('pages', 100)
    order('datePublished', 'desc')
    maxResults(10)
}

// HQL queries
Book.executeQuery(
    "from Book b where b.author.name = :name",
    [name: 'Smith']
)

// Detached criteria (reusable)
def criteria = Book.where {
    pages > 100
}
criteria.list()
criteria.count()

Transactions

// In services (default transactional)
@Transactional
class BookService {
    def saveBook(Book book) {
        book.save(failOnError: true)
    }
}

// Manual transaction control
Book.withTransaction { status ->
    def book = new Book(title: "Test")
    book.save()
    if (someCondition) {
        status.setRollbackOnly()
    }
}

Controllers

Basic Controller

class BookController {

    BookService bookService

    static allowedMethods = [save: 'POST', update: 'PUT', delete: 'DELETE']

    def index() {
        respond Book.list(), model: [bookCount: Book.count()]
    }

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

    def create() {
        respond new Book(params)
    }

    def save(Book book) {
        if (book.hasErrors()) {
            respond book.errors, view: 'create'
            return
        }
        bookService.save(book)
        redirect action: 'show', id: book.id
    }

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

    def update(Book book) {
        if (book.hasErrors()) {
            respond book.errors, view: 'edit'
            return
        }
        bookService.save(book)
        redirect action: 'show', id: book.id
    }

    def delete(Long id) {
        bookService.delete(id)
        redirect action: 'index'
    }
}

REST Controller

import grails.rest.RestfulController

class BookController extends RestfulController<Book> {

    static responseFormats = ['json', 'xml']

    BookController() {
        super(Book)
    }

    // Override to customize
    @Override
    protected Book queryForResource(Serializable id) {
        Book.where { id == id && active == true }.find()
    }
}

Command Objects

class BookCommand implements Validateable {
    String title
    String author
    Integer pages

    static constraints = {
        title blank: false
        author blank: false
        pages min: 1, nullable: true
    }
}

class BookController {
    def save(BookCommand cmd) {
        if (cmd.hasErrors()) {
            respond cmd.errors
            return
        }
        // Process valid command
    }
}

Content Negotiation

class BookController {
    def show(Long id) {
        def book = Book.get(id)
        respond book  // Auto-negotiates based on Accept header
    }
}

// Or explicit
def show(Long id) {
    def book = Book.get(id)
    withFormat {
        html { render view: 'show', model: [book: book] }
        json { render book as JSON }
        xml { render book as XML }
    }
}

Services

Basic Service

import grails.gorm.transactions.Transactional

@Transactional
class BookService {

    def findByTitle(String title) {
        Book.findByTitle(title)
    }

    def save(Book book) {
        book.save(failOnError: true)
    }

    @Transactional(readOnly = true)
    def list(Map params) {
        Book.list(params)
    }

    def delete(Long id) {
        Book.get(id)?.delete()
    }
}

Service with Events

import grails.events.annotation.Publisher
import grails.events.annotation.Subscriber

class BookService {

    @Publisher
    def save(Book book) {
        book.save()
        // Automatically publishes 'book.saved' event
    }
}

class NotificationService {

    @Subscriber('book.saved')
    def onBookSaved(Book book) {
        // Handle event
        log.info "Book saved: ${book.title}"
    }
}

Async Service

import grails.async.Promise
import static grails.async.Promises.*

class BookService {

    Promise<Book> findAsync(Long id) {
        task {
            Book.get(id)
        }
    }

    def processBooks() {
        def p1 = task { Book.findAllByActive(true) }
        def p2 = task { Author.list() }

        waitAll(p1, p2)
        // Both complete
    }
}

Views

GSP (Groovy Server Pages)

<!-- grails-app/views/book/list.gsp -->
<!DOCTYPE html>
<html>
<head>
    <title>Books</title>
    <asset:stylesheet src="application.css"/>
</head>
<body>
    <h1>Books (${bookCount})</h1>

    <g:each in="${bookList}" var="book">
        <div class="book">
            <h2>${book.title}</h2>
            <p>By ${book.author}</p>
            <g:link action="show" id="${book.id}">View</g:link>
        </div>
    </g:each>

    <g:if test="${bookList.empty}">
        <p>No books found.</p>
    </g:if>

    <g:paginate total="${bookCount}"/>

    <asset:javascript src="application.js"/>
</body>
</html>

Common GSP Tags

<!-- Links -->
<g:link controller="book" action="show" id="${book.id}">View</g:link>
<g:createLink action="list"/>

<!-- Forms -->
<g:form action="save">
    <g:textField name="title" value="${book?.title}"/>
    <g:textArea name="description" value="${book?.description}"/>
    <g:select name="category" from="${categories}" optionKey="id" optionValue="name"/>
    <g:checkBox name="active" value="${book?.active}"/>
    <g:submitButton name="submit" value="Save"/>
</g:form>

<!-- Conditionals -->
<g:if test="${condition}">...</g:if>
<g:elseif test="${other}">...</g:elseif>
<g:else>...</g:else>

<!-- Iteration -->
<g:each in="${items}" var="item" status="i">
    ${i}: ${item.name}
</g:each>

<!-- Rendering -->
<g:render template="bookRow" model="[book: book]"/>
<g:render template="bookRow" collection="${books}" var="book"/>

<!-- Security (with Spring Security plugin) -->
<sec:ifLoggedIn>Welcome, ${sec.username()}</sec:ifLoggedIn>
<sec:ifNotLoggedIn><g:link controller="login">Login</g:link></sec:ifNotLoggedIn>

JSON Views

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

model {
    Book book
}

json {
    id book.id
    title book.title
    author book.author
    datePublished book.datePublished?.format('yyyy-MM-dd')
    _links {
        self {
            href "/books/${book.id}"
        }
    }
}

Layouts

<!-- grails-app/views/layouts/main.gsp -->
<!DOCTYPE html>
<html>
<head>
    <title><g:layoutTitle default="My App"/></title>
    <asset:stylesheet src="application.css"/>
    <g:layoutHead/>
</head>
<body>
    <nav><!-- Navigation --></nav>
    <main>
        <g:layoutBody/>
    </main>
    <footer><!-- Footer --></footer>
    <asset:javascript src="application.js"/>
</body>
</html>

<!-- Use in views -->
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Books</title>
</head>
<body>
    <!-- Page content -->
</body>
</html>

Interceptors

class AuthInterceptor {

    AuthInterceptor() {
        matchAll()
            .excludes(controller: 'login')
            .excludes(controller: 'error')
    }

    boolean before() {
        if (!session.user) {
            redirect(controller: 'login')
            return false
        }
        true
    }

    boolean after() {
        // After action executes
        true
    }

    void afterView() {
        // After view renders
    }
}

Tag Libraries

class BookTagLib {

    static namespace = 'book'
    static defaultEncodeAs = [taglib: 'html']

    def formatTitle = { attrs, body ->
        def title = attrs.title ?: body()
        out << "<span class='book-title'>${title}</span>"
    }

    def ifHasBooks = { attrs, body ->
        if (Book.count() > 0) {
            out << body()
        }
    }
}

// Usage in GSP
<book:formatTitle title="${book.title}"/>
<book:ifHasBooks>
    <p>We have books!</p>
</book:ifHasBooks>

Configuration

application.yml

grails:
    profile: web
    codegen:
        defaultPackage: myapp
    gorm:
        reactor:
            events: false
    mime:
        types:
            json: ['application/json', 'text/json']
            xml: ['text/xml', 'application/xml']

environments:
    development:
        dataSource:
            dbCreate: create-drop
            url: jdbc:h2:mem:devDb
    test:
        dataSource:
            dbCreate: update
            url: jdbc:h2:mem:testDb
    production:
        dataSource:
            dbCreate: none
            url: jdbc:postgresql://localhost/myapp
            driverClassName: org.postgresql.Driver
            dialect: org.hibernate.dialect.PostgreSQLDialect
            properties:
                jmxEnabled: true
                initialSize: 5
                maxActive: 50

server:
    port: 8080
    servlet:
        context-path: /myapp

URL Mappings

// grails-app/controllers/myapp/UrlMappings.groovy
class UrlMappings {

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

        // REST resources
        "/api/books"(resources: 'book')

        // Custom mappings
        "/books/$id"(controller: 'book', action: 'show')
        "/search"(controller: 'book', action: 'search')

        // Named URL mappings
        name bookShow: "/books/$id" {
            controller = 'book'
            action = 'show'
        }

        // Error pages
        "500"(view: '/error')
        "404"(view: '/notFound')
    }
}

Testing with Spock

Unit Test - Controller

import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification

class BookControllerSpec extends Specification
        implements ControllerUnitTest<BookController> {

    def setup() {
        controller.bookService = Mock(BookService)
    }

    void "index returns list of books"() {
        given:
        def books = [new Book(title: "Test")]
        controller.bookService.list(_) >> books

        when:
        controller.index()

        then:
        model.bookList == books
        view == '/book/index'
    }

    void "show returns 404 for missing book"() {
        when:
        controller.show(999)

        then:
        response.status == 404
    }
}

Unit Test - Service

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

class BookServiceSpec extends Specification
        implements ServiceUnitTest<BookService> {

    void "save persists book"() {
        given:
        def book = new Book(title: "Test", author: "Author")

        when:
        def result = service.save(book)

        then:
        result.id != null
        Book.count() == 1
    }
}

Unit Test - Domain

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

class BookSpec extends Specification
        implements DomainUnitTest<Book> {

    void "title cannot be blank"() {
        when:
        domain.title = ""
        domain.author = "Test"

        then:
        !domain.validate(['title'])
        domain.errors['title'].code == 'blank'
    }

    void "valid book passes validation"() {
        when:
        domain.title = "Valid Title"
        domain.author = "Author"

        then:
        domain.validate()
    }
}

Integration Test

import grails.testing.mixin.integration.Integration
import grails.gorm.transactions.Rollback
import spock.lang.Specification

@Integration
@Rollback
class BookServiceIntegrationSpec extends Specification {

    BookService bookService

    void "save and retrieve book"() {
        given:
        def book = new Book(title: "Integration Test", author: "Tester")

        when:
        bookService.save(book)
        def found = Book.findByTitle("Integration Test")

        then:
        found != null
        found.author == "Tester"
    }
}

Functional Test with Geb

import geb.spock.ContainerGebSpec
import grails.testing.mixin.integration.Integration

@Integration
class BookFunctionalSpec extends ContainerGebSpec {

    void "can view book list"() {
        when:
        go '/book/index'

        then:
        title == 'Book List'
        $('h1').text() == 'Books'
    }

    void "can create new book"() {
        when:
        go '/book/create'
        $('form').title = 'New Book'
        $('form').author = 'Test Author'
        $('input[type=submit]').click()

        then:
        $('h1').text().contains('New Book')
    }
}

Plugins

Common Plugins

// build.gradle

// Spring Security
implementation 'org.apache.grails.plugins:grails-spring-security-core:7.0.1'

// Database Migration (Liquibase)
implementation 'org.apache.grails.plugins:grails-database-migration:5.0.1'

// Caching
implementation 'org.apache.grails.plugins:grails-cache:7.0.0'

// Async support
implementation 'org.apache.grails.plugins:grails-async:7.0.0'

// Fields plugin for form rendering
implementation 'org.apache.grails.plugins:grails-fields:7.0.0'

// Asset Pipeline
runtimeOnly 'com.bertramlabs.plugins:asset-pipeline-grails:5.0.8'

Spring Security Configuration

// grails-app/conf/application.groovy
grails.plugin.springsecurity.userLookup.userDomainClassName = 'myapp.User'
grails.plugin.springsecurity.userLookup.authorityJoinClassName = 'myapp.UserRole'
grails.plugin.springsecurity.authority.className = 'myapp.Role'

grails.plugin.springsecurity.controllerAnnotations.staticRules = [
    [pattern: '/',               access: ['permitAll']],
    [pattern: '/error',          access: ['permitAll']],
    [pattern: '/index',          access: ['permitAll']],
    [pattern: '/admin/**',       access: ['ROLE_ADMIN']],
    [pattern: '/api/**',         access: ['isAuthenticated()']],
]

Build Commands

# Run application
./gradlew bootRun

# Run tests
./gradlew test                                    # All tests
./gradlew test --tests "BookServiceSpec"          # Single spec
./gradlew test --tests "*Spec.save*"              # Pattern match
./gradlew integrationTest                         # Integration tests

# Build
./gradlew build                                   # Build with tests
./gradlew bootJar                                 # Executable JAR
./gradlew bootWar                                 # WAR file

# Code quality
./gradlew check                                   # Run all verification tasks

# Clean
./gradlew clean

Best Practices

Convention Over Configuration

  • Follow Grails naming conventions for automatic wiring.
  • Use standard directory structure.
  • Let Grails infer configurations where possible.

Performance

  • Use @GrailsCompileStatic on controllers, services, and domain classes.
  • Enable query caching for read-heavy operations.
  • Use pagination for large result sets.
  • Avoid N+1 queries with eager fetching or batch size.

Security

  • Use CSRF tokens in forms: <g:form useToken="true">.
  • Apply Spring Security for authentication/authorization.
  • Use parameterized queries (GORM handles this automatically).
  • Encode output to prevent XSS.

Testing

  • Write unit tests for domain constraints.
  • Test controllers with ControllerUnitTest.
  • Use integration tests for service/database interactions.
  • Use Geb for critical user workflows.

Code Style

  • Use @GrailsCompileStatic or @CompileStatic for type safety and performance.
  • Prefer services for business logic over controllers.
  • Use command objects for complex form handling.
  • Keep controllers thin, services fat.

Resources

Signals

GitHub stars
3k
Forks
975
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
grails-developer
Source
github.com/apache/grails-core