Creating your first Grails Application

Learn how to create your first Grails app

Authors: Zachary Klein, Sanjana

Grails Version: 8

1 Introduction

Build a first Apache Grails 8 web application: domain classes, controllers, GSP views, Asset Pipeline, GORM data services, scaffolding, and Spock tests.

This guide follows the standard Grails guides layout: work in the initial/ project and compare your progress with complete/.

2 What you will need

  • Approximately 60 minutes

  • JDK 21 (Apache Grails 8 requires Java 21)

  • A text editor or IDE

  • The Gradle wrapper bundled in initial/ and complete/ (no separate Grails SDK install required)

  • Docker, if you want to run Geb integration tests (./gradlew integrationTest)

3 Getting Started

Clone the repository and verify the starter project:

git clone -b grails8 https://github.com/grails-guides/creating-your-first-grails-app.git
cd creating-your-first-grails-app/initial
./gradlew test

The initial/ project is a Grails 8 web starter from start.grails.org (web profile, GSP, H2, scaffolding, Spock, Geb). It has no domain model yet.

To skip ahead, cd ../complete and run the same commands; that tree contains the finished Make / Model / Vehicle sample.

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

4 Creating the app

You can recreate initial/ yourself with the Grails Application Forge:

curl -O https://start.grails.org/creating-your-first-grails-app.zip \
  -d version=8.0.0-SNAPSHOT \
  -d profile=web \
  -d features=gsp,hibernate5,scaffolding,geb,h2
unzip creating-your-first-grails-app.zip

Or use the web UI, choose Grails 8, the web profile, and the features you need, then download the zip.

This guide uses the cloned initial/ / complete/ trees so paths and package names (example) stay consistent with the sample.

5 Running the app

From complete/ (or your working copy of initial/ once you have added the domain model):

./gradlew bootRun

Open http://localhost:8080/. You should see the home page with seeded vehicles. Try /vehicle/index, /make/index, and /model/index as well.

The default server port is 8080. To change it, set server.port in grails-app/conf/application.yml or pass -Dserver.port=8090 to Gradle.

6 Domain classes

Model a simple vehicle inventory with three domain classes under grails-app/domain/example/.

Make is a plain name:

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

class Make {

    String name

    static constraints = {
        name blank: false, maxSize: 255
    }

    String toString() {
        name
    }
}

Model belongs to a Make:

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

class Model {

    String name

    static belongsTo = [make: Make]

    static constraints = {
        name blank: false, maxSize: 255
    }

    String toString() {
        name
    }
}

Vehicle ties name, year, make, and model together:

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

class Vehicle {

    String name
    Integer year
    Make make
    Model model

    static constraints = {
        name blank: false, maxSize: 255
        year min: 1900
    }

    // H2 treats YEAR as a reserved word; quote/rename the physical column.
    static mapping = {
        year column: 'vehicle_year'
    }

    String toString() {
        name
    }
}

GORM maps these classes to tables automatically when you run the app against H2 (the starter default).

H2 treats YEAR as a reserved word, so Vehicle maps the year property to a vehicle_year column. Without that mapping, schema generation fails when you run the app or ./gradlew integrationTest.

7 Controllers and scaffolding

Map the root URL to a home controller and use scaffolding for Make / Model CRUD.

Point / at HomeController:

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

class UrlMappings {

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

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

HomeController lists vehicles and lets the visitor update a session name:

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

import grails.gorm.transactions.ReadOnly

class HomeController {

    @ReadOnly
    def index() {
        respond([
            name: session.name ?: 'User',
            vehicleList: Vehicle.list(),
            vehicleTotal: Vehicle.count()
        ])
    }

    def updateName(String name) {
        session.name = name
        flash.message = 'Name has been updated'
        redirect action: 'index'
    }
}

For Make and Model, Grails 8 scaffolding is annotation-based:

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

import grails.plugin.scaffolding.annotation.Scaffold

@Scaffold(Make)
class MakeController {
}
grails-app/controllers/example/ModelController.groovy
package example

import grails.plugin.scaffolding.annotation.Scaffold

@Scaffold(Model)
class ModelController {
}

VehicleController is a hand-written CRUD controller. The show action adds an estimated value from ValueEstimateService:

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

import static org.springframework.http.HttpStatus.CREATED
import static org.springframework.http.HttpStatus.NOT_FOUND
import static org.springframework.http.HttpStatus.NO_CONTENT
import static org.springframework.http.HttpStatus.OK

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

@ReadOnly
class VehicleController {

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

    ValueEstimateService valueEstimateService

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

    def show(Vehicle vehicle) {
        respond vehicle, model: [estimatedValue: valueEstimateService.getEstimate(vehicle)]
    }

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

    @Transactional
    def save(Vehicle vehicle) {
        if (vehicle == null) {
            notFound()
            return
        }

        if (vehicle.hasErrors()) {
            transactionStatus.setRollbackOnly()
            respond vehicle.errors, view: 'create'
            return
        }

        vehicle.save flush: true

        request.withFormat {
            form multipartForm {
                flash.message = message(
                    code: 'default.created.message',
                    args: [message(code: 'vehicle.label', default: 'Vehicle'), vehicle.id]
                )
                redirect vehicle
            }
            '*' { respond vehicle, [status: CREATED] }
        }
    }

    def edit(Vehicle vehicle) {
        respond vehicle
    }

    @Transactional
    def update(Vehicle vehicle) {
        if (vehicle == null) {
            notFound()
            return
        }

        if (vehicle.hasErrors()) {
            transactionStatus.setRollbackOnly()
            respond vehicle.errors, view: 'edit'
            return
        }

        vehicle.save flush: true

        request.withFormat {
            form multipartForm {
                flash.message = message(
                    code: 'default.updated.message',
                    args: [message(code: 'vehicle.label', default: 'Vehicle'), vehicle.id]
                )
                redirect vehicle
            }
            '*' { respond vehicle, [status: OK] }
        }
    }

    @Transactional
    def delete(Vehicle vehicle) {
        if (vehicle == null) {
            notFound()
            return
        }

        vehicle.delete flush: true

        request.withFormat {
            form multipartForm {
                flash.message = message(
                    code: 'default.deleted.message',
                    args: [message(code: 'vehicle.label', default: 'Vehicle'), vehicle.id]
                )
                redirect action: 'index', method: 'GET'
            }
            '*' { render status: NO_CONTENT }
        }
    }

    protected void notFound() {
        request.withFormat {
            form multipartForm {
                flash.message = message(
                    code: 'default.not.found.message',
                    args: [message(code: 'vehicle.label', default: 'Vehicle'), params.id]
                )
                redirect action: 'index', method: 'GET'
            }
            '*' { render status: NOT_FOUND }
        }
    }
}

8 GSP views

GSPs under grails-app/views/ render the HTML. The home page uses <g:each> and <g:link>:

grails-app/views/home/index.gsp
<html>
<head>
    <meta name="layout" content="main"/>
    <title>Home Page</title>
</head>
<body>

<div id="content" role="main">
    <section class="row colset-2-its">
        <h1>Welcome ${name}!</h1>

        <g:if test="${flash.message}">
            <div class="message" role="status">${flash.message}</div>
        </g:if>

        <p>There are ${vehicleTotal} vehicles in the database.</p>

        <ul>
            <g:each in="${vehicleList}" var="vehicle">
                <li>
                    <g:link controller="vehicle" action="show" id="${vehicle.id}">
                        ${vehicle.name} - ${vehicle.year} ${vehicle.make.name} ${vehicle.model.name}
                    </g:link>
                </li>
            </g:each>
        </ul>

        <g:form action="updateName" style="margin: 0 auto; width: 320px">
            <g:textField name="name" value="" placeholder="${name}"/>
            <g:submitButton name="Update name"/>
        </g:form>
    </section>
</div>

</body>
</html>

Vehicle list and form pages use the Fields plugin tags (<f:table>, <f:all>, <f:display>). The list view:

grails-app/views/vehicle/index.gsp
<!DOCTYPE html>
<html>
    <head>
        <meta name="layout" content="main"/>
        <g:set var="entityName" value="${message(code: 'vehicle.label', default: 'Vehicle')}"/>
        <title><g:message code="default.list.label" args="[entityName]"/></title>
    </head>
    <body>
        <a href="#list-vehicle" class="skip" tabindex="-1">
            <g:message code="default.link.skip.label" default="Skip to content&hellip;"/>
        </a>
        <div class="nav" role="navigation">
            <ul>
                <li><a class="home" href="${createLink(uri: '/')}"><g:message code="default.home.label"/></a></li>
                <li><g:link class="create" action="create"><g:message code="default.new.label" args="[entityName]"/></g:link></li>
            </ul>
        </div>
        <div id="list-vehicle" class="content scaffold-list" role="main">
            <h1><g:message code="default.list.label" args="[entityName]"/></h1>
            <g:if test="${flash.message}">
                <div class="message" role="status">${flash.message}</div>
            </g:if>
            <f:table collection="${vehicleList}"/>
            <div class="pagination">
                <g:paginate total="${vehicleCount ?: 0}"/>
            </div>
        </div>
    </body>
</html>

The show page displays the estimated value next to the vehicle:

grails-app/views/vehicle/show.gsp
<!DOCTYPE html>
<html>
    <head>
        <meta name="layout" content="main"/>
        <g:set var="entityName" value="${message(code: 'vehicle.label', default: 'Vehicle')}"/>
        <title><g:message code="default.show.label" args="[entityName]"/></title>
    </head>
    <body>
        <a href="#show-vehicle" class="skip" tabindex="-1">
            <g:message code="default.link.skip.label" default="Skip to content&hellip;"/>
        </a>
        <div class="nav" role="navigation">
            <ul>
                <li><a class="home" href="${createLink(uri: '/')}"><g:message code="default.home.label"/></a></li>
                <li><g:link class="list" action="index"><g:message code="default.list.label" args="[entityName]"/></g:link></li>
                <li><g:link class="create" action="create"><g:message code="default.new.label" args="[entityName]"/></g:link></li>
            </ul>
        </div>
        <div id="show-vehicle" class="content scaffold-show" role="main">
            <h1><g:message code="default.show.label" args="[entityName]"/></h1>
            <h1>
                Estimated Value:
                <g:formatNumber number="${estimatedValue}" type="currency" currencyCode="USD"/>
            </h1>
            <g:if test="${flash.message}">
                <div class="message" role="status">${flash.message}</div>
            </g:if>
            <f:display bean="vehicle"/>
            <g:form resource="${this.vehicle}" method="DELETE">
                <fieldset class="buttons">
                    <g:link class="edit" action="edit" resource="${this.vehicle}">
                        <g:message code="default.button.edit.label" default="Edit"/>
                    </g:link>
                    <input class="delete" type="submit"
                           value="${message(code: 'default.button.delete.label', default: 'Delete')}"
                           onclick="return confirm('${message(code: 'default.button.delete.confirm.message', default: 'Are you sure?')}');"/>
                </fieldset>
            </g:form>
        </div>
    </body>
</html>

Create and edit forms live in create.gsp and edit.gsp and call <f:all bean="vehicle"/>.

9 Asset Pipeline

The web starter ships with the Asset Pipeline plugin (cloud.wondrify:asset-pipeline-grails). It manages CSS, JavaScript, and images under grails-app/assets/ and exposes them with <asset:*> tags.

Common tags:

<asset:javascript src="application.js"/>
<asset:stylesheet src="application.css"/>
<asset:image src="grails.svg" alt="Grails Logo"/>

Convention:

  • grails-app/assets/javascripts for JS

  • grails-app/assets/stylesheets for CSS

  • grails-app/assets/images for images

The default layout already wires the manifests:

grails-app/views/layouts/main.gsp
<head>
    ...
    <asset:link rel="icon" href="favicon.ico" type="image/x-ico"/>
    <asset:stylesheet src="application.css"/>
    ...
</head>
<body>
    ...
    <asset:image src="grails.svg" alt="Grails Logo"/>
    ...
    <asset:javascript src="application.js"/>
</body>

application.css and application.js are manifest files. In Grails 8 they pull Bootstrap and jQuery from WebJars:

grails-app/assets/stylesheets/application.css
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS file within this directory can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the top of the
* compiled file, but it's generally better to create a new file per style scope.
*
*= require webjars/bootstrap/%/dist/css/bootstrap.css
*= require webjars/bootstrap-icons/%/font/bootstrap-icons.css
*= require grails.css
*= require_self
*/
grails-app/assets/javascripts/application.js
// This is a manifest file that'll be compiled into application.js.
//
// Any JavaScript file within this directory can be referenced here using a relative path.
//
// You're free to add application-wide JavaScript to this file, but it's generally better
// to create separate JavaScript files as needed.
//
//= require webjars/jquery/%/dist/jquery.js
//= require webjars/bootstrap/%/dist/js/bootstrap.bundle.js
//= require_self

if (typeof jQuery !== 'undefined') {
    (function($) {
        $('#spinner').ajaxStart(function() {
            $(this).fadeIn();
        }).ajaxStop(function() {
            $(this).fadeOut();
        });
    })(jQuery);
}

To add your own script, create grails-app/assets/javascripts/site.js and require it from the manifest:

grails-app/assets/javascripts/application.js
//= require webjars/jquery/%/dist/jquery.js
//= require webjars/bootstrap/%/dist/js/bootstrap.bundle.js
//= require site
//= require_self
grails-app/assets/javascripts/site.js
console.log('site.js loaded')

Reload http://localhost:8080/ and check the browser console. The same pattern works for CSS: add a file under stylesheets/ and *= require it from application.css.

At build time Asset Pipeline concatenates and can minify these assets for production.

10 Services

Prefer GORM data services for persistence and a small transactional service for domain logic.

Data services seed BootStrap without writing repository boilerplate:

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

import grails.gorm.services.Service

@Service(Make)
interface MakeService {
    Make save(String name)
}
grails-app/services/example/ModelService.groovy
package example

import grails.gorm.services.Service

@Service(Model)
interface ModelService {
    Model save(String name, Make make)
}
grails-app/services/example/VehicleService.groovy
package example

import grails.gorm.services.Service

@Service(Vehicle)
interface VehicleService {
    Vehicle save(String name, Make make, Model model, Integer year)
}

BootStrap uses those services to insert sample rows on startup:

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

import groovy.transform.CompileStatic

@CompileStatic
class BootStrap {

    MakeService makeService
    ModelService modelService
    VehicleService vehicleService

    def init = {
        Make nissan = makeService.save('Nissan')
        Make ford = makeService.save('Ford')

        Model titan = modelService.save('Titan', nissan)
        Model leaf = modelService.save('Leaf', nissan)
        Model windstar = modelService.save('Windstar', ford)

        vehicleService.save('Pickup', nissan, titan, 2012)
        vehicleService.save('Economy', nissan, leaf, 2014)
        vehicleService.save('Minivan', ford, windstar, 1990)
    }

    def destroy = {
    }
}

ValueEstimateService is a regular Grails service used by VehicleController.show:

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

import grails.gorm.transactions.Transactional

@Transactional
class ValueEstimateService {

    def getEstimate(Vehicle vehicle) {
        // Placeholder — real apps would call a valuation API here
        Math.round(vehicle.name.size() + vehicle.model.name.size() * vehicle.year) * 2
    }
}

11 Testing

Unit-test the estimate service with Spock and the Grails testing mixins:

src/test/groovy/example/ValueEstimateServiceSpec.groovy
package example

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

class ValueEstimateServiceSpec extends Specification
        implements ServiceUnitTest<ValueEstimateService>, DataTest {

    void setupSpec() {
        mockDomains Make, Model, Vehicle
    }

    void 'estimate is a sensible positive number'() {
        given:
        def make = new Make(name: 'Test')
        def model = new Model(name: 'Test', make: make)
        def vehicle = new Vehicle(year: 2000, make: make, model: model, name: 'Test Vehicle')

        when:
        def estimate = service.getEstimate(vehicle)

        then:
        estimate > 0
        estimate < 1_000_000
    }
}

Run unit tests:

./gradlew test

Geb integration tests use Testcontainers (ContainerGebSpec). Docker must be running:

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

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

@Integration
class HomePageSpec extends ContainerGebSpec {

    void 'home page lists seeded vehicles'() {
        when:
        go '/'

        then:
        title == 'Home Page'
        $('h1').text().contains('Welcome')
        $('li a', text: contains('Pickup')).size() == 1
    }
}
./gradlew integrationTest

12 Summary

You built a first Grails 8 web app:

  • Domain classes for Make, Model, and Vehicle

  • Annotation scaffolding for Make / Model and a custom VehicleController

  • GSP views with Fields tags and a home page that lists seeded vehicles

  • Asset Pipeline manifests for CSS, JavaScript, and images (including WebJars)

  • GORM data services plus ValueEstimateService

  • Spock unit tests and a Geb integration spec

Next steps: add validation messages, secure the CRUD actions, or swap H2 for PostgreSQL in production.

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