Grails 6 Production Guide: GORM Performance, Plugins & Microservices Migration
Running Grails in production without tuning GORM is like running Hibernate without tuning — technically functional, practically painful. This guide covers the performance optimizations, plugin configurations, and observability practices that separate a Grails app that struggles at 500 req/min from one that handles 5,000 req/min — and the incremental migration path to microservices when your monolith has done its job.
Software Engineer · Java · Spring Boot · Grails · GORM
Grails 6 + Spring Boot 3: What Changed
Grails 6 is built on top of Spring Boot 3.x and Hibernate 6, which brings:
- Jakarta EE namespace —
javax.*→jakarta.*. Update any third-party JAR that importsjavax.persistence.* - Hibernate 6 + GORM 9 — improved schema generation, better type mapping, and native UUID support
- Java 17+ baseline — records, sealed classes, and text blocks work in Groovy 4 via interop
- HikariCP 5 — the default connection pool (replaced DBCP2)
- Micronaut for Grails — optional AOT-compiled dependency injection for faster startup
Upgrading build.gradle for Grails 6
// build.gradle
plugins {
id "groovy"
id "org.grails.grails-web" version "6.2.0"
id "org.grails.grails-gsp" version "6.2.0"
id "com.github.erdi.webdriver-binaries" version "3.2"
id "war"
id "idea"
id "com.bertramlabs.asset-pipeline" version "4.3.0"
}
grails {
plugins {
implementation "org.grails.plugins:cache:5.0.1"
implementation "org.grails.plugins:spring-security-core:6.1.0"
implementation "org.grails.plugins:grails-rest:2.3.0"
}
}
dependencies {
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-security"
runtimeOnly "com.mysql:mysql-connector-j"
runtimeOnly "com.h2database:h2" // test
}
GORM Performance: The N+1 Problem
The N+1 query problem is the most common Grails performance killer. It occurs when loading a collection property triggers N additional SQL queries instead of one JOIN.
Diagnosing N+1 with SQL Logging
# grails-app/conf/logback.groovy
appender("STDOUT", ConsoleAppender) {
encoder(PatternLayoutEncoder) {
pattern = "%level %logger - %msg%n"
}
}
logger("org.hibernate.SQL", DEBUG, ["STDOUT"])
logger("org.hibernate.type.descriptor.sql.BasicBinder", TRACE, ["STDOUT"])
root(ERROR, ["STDOUT"])
Solving N+1 with Eager Loading and Fetch Joins
// Domain class — default is lazy loading (triggers N+1)
class Order {
Customer customer
List<OrderItem> items
static hasMany = [items: OrderItem]
}
// GORM query that triggers N+1:
def orders = Order.findAll()
orders.each { order ->
println order.customer.name // N additional SELECT per order — BAD
}
// Fix 1: Eager fetch using criteria
def orders = Order.createCriteria().list {
fetchMode "customer", FetchMode.JOIN
fetchMode "items", FetchMode.JOIN
}
// Fix 2: HQL fetch join
def orders = Order.executeQuery("""
SELECT DISTINCT o FROM Order o
JOIN FETCH o.customer
LEFT JOIN FETCH o.items
""")
// Fix 3: Static mapping with batch size (best for collections)
class Order {
Customer customer
List<OrderItem> items
static hasMany = [items: OrderItem]
static mapping = {
items batchSize: 25 // loads items in batches of 25 — reduces N+1 to N/25+1
customer fetch: "join" // always join-fetch the customer
}
}
GORM Second-Level Cache with Redis
Hibernate's first-level cache (session cache) only lasts for the duration of a request. The second-level cache persists across requests and dramatically reduces database load for frequently read, rarely updated entities.
# grails-app/conf/application.yml
hibernate:
cache:
use_second_level_cache: true
use_query_cache: true
region:
factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
javax:
cache:
provider: org.redisson.jcache.JCachingProvider
uri: classpath:redisson.yaml
# redisson.yaml
singleServerConfig:
address: "redis://localhost:6379"
connectionPoolSize: 10
idleConnectionTimeout: 10000
// Enable caching on domain class
class Product {
String sku
String name
BigDecimal price
boolean active
static mapping = {
cache true // second-level cache
cache usage: 'read-write' // or 'nonstrict-read-write' for lower consistency
}
}
// GORM query cache — cache the result set of a query
def popularProducts = Product.findAllByActive(true, [cache: true])
// Programmatic cache eviction when product is updated
productService.clearProductCache() // call this in your service after updates
// Service-level cache eviction
class ProductService {
def sessionFactory
void clearProductCache() {
sessionFactory.cache.evictEntityData(Product)
sessionFactory.cache.evictQueryRegions()
}
}
HikariCP Connection Pool Tuning
The default HikariCP configuration works for development but will cause connection pool exhaustion under production load. Tune these settings based on your database tier and traffic profile:
# grails-app/conf/application.yml
dataSource:
pooled: true
url: jdbc:mysql://prod-db:3306/myapp?useSSL=true&serverTimezone=UTC
username: ${DB_USER}
password: ${DB_PASSWORD}
driverClassName: com.mysql.cj.jdbc.Driver
properties:
# HikariCP tuning — critical for production
maximumPoolSize: 20 # (CPU cores × 2) + effective_spindle_count
minimumIdle: 5 # keep 5 connections warm
connectionTimeout: 30000 # 30s — time to wait for a connection from pool
idleTimeout: 600000 # 10min — remove idle connections
maxLifetime: 1800000 # 30min — recycle connections before MySQL's wait_timeout
keepaliveTime: 60000 # 60s — test idle connections (avoids broken pipe)
connectionTestQuery: "SELECT 1" # for MySQL; remove for PostgreSQL (uses JDBC ping)
leakDetectionThreshold: 60000 # 60s — log connection leaks in dev/staging
Warning: maximumPoolSize should not exceed your database's max_connections divided by the number of application instances. For a MySQL RDS db.t3.medium with default 151 connections and 3 app instances, set maximumPoolSize to no more than 45.
Read-Only Transactions for Query-Heavy Endpoints
Hibernate's dirty-check mechanism snapshots every entity on load and compares it to the snapshot on flush. For read-only operations, this is pure overhead. Mark your read-heavy services as @Transactional(readOnly = true):
import org.springframework.transaction.annotation.Transactional
class ReportService {
// readOnly = true skips dirty-check, disables entity tracking
// Can improve throughput by 10–30% for complex queries
@Transactional(readOnly = true)
List<OrderSummary> monthlySummary(int year, int month) {
Order.executeQuery("""
SELECT new com.example.OrderSummary(
o.status, COUNT(o), SUM(o.total)
)
FROM Order o
WHERE YEAR(o.createdAt) = :year AND MONTH(o.createdAt) = :month
GROUP BY o.status
""", [year: year, month: month])
}
// NEVER use readOnly = true if the method writes to the database
// Hibernate skips flushing on readOnly transactions — writes will be silently ignored
@Transactional
void completeOrder(Long orderId) {
def order = Order.get(orderId)
order.status = 'COMPLETED'
order.save(flush: true) // explicit flush to guarantee persistence
}
}
GORM Batch Processing for Bulk Operations
Loading thousands of records into memory and processing them one by one causes heap exhaustion. Use withBatch and scroll cursors for bulk operations:
// Batch insert — avoid N INSERT statements
import grails.gorm.transactions.Transactional
@Transactional
void bulkCreateProducts(List<ProductDto> dtos) {
// GORM withBatch groups inserts into batch statements
Product.withBatch(50) { session ->
dtos.each { dto ->
new Product(sku: dto.sku, name: dto.name, price: dto.price)
.save(validate: false) // skip validation for trusted ETL data
if (i++ % 50 == 0) {
session.flush()
session.clear() // clear session to free memory
}
}
}
}
// Scroll cursor for large result sets — avoids loading all rows into memory
void exportAllOrders(OutputStream out) {
Order.withStatelessSession { session ->
def criteria = session.createCriteria(Order)
criteria.setFetchSize(100)
criteria.scroll(ScrollMode.FORWARD_ONLY).each { Order order ->
out.write(orderToCsvRow(order))
}
}
}
Key Grails 6 Plugins for Production
1. Spring Security Core 6.x
# grails-app/conf/application.yml
grails:
plugin:
springsecurity:
userLookup:
userDomainClassName: com.example.User
authorityJoinClassName: com.example.UserRole
authority:
className: com.example.Role
filterChain:
chainMap:
- pattern: /api/**
filters: JOINED_FILTERS,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter
- pattern: /**
filters: JOINED_FILTERS
rest:
token:
storage:
useSession: false
validation:
headerName: Authorization
enableAnonymousAccess: true
2. Grails REST + JSON Views
// grails-app/views/api/book/_book.gson — JSON view
import com.example.Book
model {
Book book
}
json {
id book.id
title book.title
author book.author
price book.price
createdAt book.dateCreated?.format("yyyy-MM-dd'T'HH:mm:ss'Z'")
}
3. Grails Async Plugin
// Asynchronous service method using Promises
import static grails.async.Promises.*
class EmailService {
def mailService
void sendWelcomeEmailAsync(User user) {
task {
// Runs in a background thread from Grails async thread pool
mailService.sendMail {
to user.email
subject "Welcome to our platform!"
body view: "/emails/welcome", model: [user: user]
}
}.onError { Throwable t ->
log.error "Failed to send welcome email to ${user.email}", t
}
}
}
Observability: Metrics & Health Checks
Grails 6 inherits Spring Boot Actuator endpoints. Enable and secure them for production monitoring:
# application.yml
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
base-path: /actuator
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # /actuator/health/liveness and /actuator/health/readiness
metrics:
export:
prometheus:
enabled: true
security:
enabled: true
// Custom GORM health indicator
import org.springframework.boot.actuate.health.Health
import org.springframework.boot.actuate.health.HealthIndicator
import org.springframework.stereotype.Component
@Component
class GormHealthIndicator implements HealthIndicator {
@Override
Health health() {
try {
def count = Product.count()
Health.up().withDetail("productCount", count).build()
} catch (Exception e) {
Health.down().withException(e).build()
}
}
}
Microservices Migration: The Strangler Fig Pattern
The Strangler Fig is the safest way to migrate a Grails monolith to microservices. New capabilities are built as standalone services; the monolith is hollowed out one domain at a time.
Phase 1: Expose the Monolith as an Internal API
// grails-app/controllers/api/v1/BookController.groovy
// Expose internal domain as a versioned REST API — consumed by new microservices
import grails.rest.RestfulController
class BookController extends RestfulController<Book> {
static responseFormats = ['json']
BookController() {
super(Book)
}
// Add custom endpoints beyond CRUD
def search() {
def results = Book.findAllByTitleLike("%${params.q}%", [max: 20])
respond results
}
}
// UrlMappings.groovy
class UrlMappings {
static mappings = {
"/api/v1/$controller/$action?/$id?(.$format)?"() {
namespace = "api.v1"
constraints { id matches: /\d+/ }
}
}
}
Phase 2: New Microservices Call the Monolith
// New Spring Boot microservice — calls Grails monolith via REST
@Service
public class BookCatalogClient {
private final WebClient webClient;
public BookCatalogClient(@Value("${grails.base-url}") String baseUrl, WebClient.Builder builder) {
this.webClient = builder.baseUrl(baseUrl).build();
}
public Mono<BookDto> findById(Long id) {
return webClient.get()
.uri("/api/v1/books/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, resp ->
Mono.error(new BookNotFoundException(id)))
.bodyToMono(BookDto.class);
}
public Flux<BookDto> search(String query) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/api/v1/books/search")
.queryParam("q", query)
.build())
.retrieve()
.bodyToFlux(BookDto.class);
}
}
Phase 3: API Gateway Traffic Splitting
# Spring Cloud Gateway — split traffic between Grails monolith and new microservices
spring:
cloud:
gateway:
routes:
# New microservice handles inventory — route there first
- id: inventory-service
uri: http://inventory-service:8081
predicates:
- Path=/api/v2/inventory/**
# New microservice handles reviews
- id: review-service
uri: http://review-service:8082
predicates:
- Path=/api/v2/reviews/**
# Everything else goes to Grails monolith
- id: grails-monolith
uri: http://grails-app:8080
predicates:
- Path=/**
filters:
- StripPrefix=0
Phase 4: Domain Event Publishing (Event-Driven Decoupling)
// Grails domain class publishes domain events to Kafka
// Keeps the monolith as source of truth while microservices react to changes
class Order {
String status
BigDecimal total
Customer customer
def afterInsert() {
publishEvent(new OrderCreatedEvent(this))
}
def afterUpdate() {
if (isDirty('status')) {
publishEvent(new OrderStatusChangedEvent(this, getPersistentValue('status'), status))
}
}
}
// Event publisher Grails service
class DomainEventPublisher {
def kafkaTemplate // Spring Kafka autowired by Spring Boot
void publishEvent(Object event) {
def payload = new ObjectMapper().writeValueAsString(event)
kafkaTemplate.send("domain-events", payload)
}
}
GORM Performance Checklist for Production
| Area | Action | Impact |
|---|---|---|
| N+1 Queries | Use fetchMode JOIN or batchSize |
Very High |
| 2nd Level Cache | Enable Redis cache for read-heavy entities | Very High |
| Read-Only TX | @Transactional(readOnly=true) on queries |
High |
| Connection Pool | Tune HikariCP maximumPoolSize |
High |
| Bulk Ops | Use withBatch + session.clear() |
High |
| Query Cache | Enable query cache for static lookup tables | Medium |
| SQL Logging | Log slow queries (Hibernate statistics) | Diagnostic |
| Projection Queries | Use DTO projections instead of full entities | Medium |
Grails Deployment: Docker & Kubernetes
# Dockerfile — multi-stage Grails 6 build
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootJar --no-daemon -q
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S grails && adduser -S grails -G grails
COPY --from=builder /app/build/libs/*.jar app.jar
USER grails
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseG1GC", \
"-XX:MaxRAMPercentage=75.0", \
"-Dfile.encoding=UTF-8", \
"-jar", "app.jar"]
# Kubernetes deployment with health probes
apiVersion: apps/v1
kind: Deployment
metadata:
name: grails-app
spec:
replicas: 3
selector:
matchLabels:
app: grails-app
template:
metadata:
labels:
app: grails-app
spec:
containers:
- name: grails-app
image: myregistry/grails-app:6.2.0
ports:
- containerPort: 8080
env:
- name: DB_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 15
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
Conclusion
Grails 6 in production is not a liability — it is a powerful, mature framework that will serve you well if you know its performance knobs. Eliminate N+1 queries with fetch joins and batch sizes, enable Redis-backed second-level caching for read-heavy entities, tune HikariCP for your database tier, and use read-only transactions everywhere you are only reading data.
When the time comes to extract microservices, the Strangler Fig pattern lets you do it incrementally — exposing the monolith as an internal REST API, building new capabilities as independent services, and routing traffic via an API gateway. The Grails community is smaller than Spring Boot's but it is loyal, active, and deeply knowledgeable. You will not be alone.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Grails · GORM