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/andcomplete/(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:
package example
class Make {
String name
static constraints = {
name blank: false, maxSize: 255
}
String toString() {
name
}
}
Model belongs to a Make:
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:
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:
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:
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:
package example
import grails.plugin.scaffolding.annotation.Scaffold
@Scaffold(Make)
class MakeController {
}
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:
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>:
<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:
<!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…"/>
</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:
<!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…"/>
</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/javascriptsfor JS -
grails-app/assets/stylesheetsfor CSS -
grails-app/assets/imagesfor images
The default layout already wires the manifests:
<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:
/*
* 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
*/
// 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:
//= require webjars/jquery/%/dist/jquery.js
//= require webjars/bootstrap/%/dist/js/bootstrap.bundle.js
//= require site
//= require_self
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:
package example
import grails.gorm.services.Service
@Service(Make)
interface MakeService {
Make save(String name)
}
package example
import grails.gorm.services.Service
@Service(Model)
interface ModelService {
Model save(String name, Make make)
}
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:
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:
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:
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:
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, andVehicle -
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.
-
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.