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.

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Grails · GORM

Grails April 5, 2026 18 min read JVM Framework Series
Grails 6 production GORM performance optimization

Grails 6 + Spring Boot 3: What Changed

Grails 6 Production Architecture GORM Performance | mdsanwarhossain.me
Grails 6 Production Architecture with GORM Performance Tuning — mdsanwarhossain.me

Grails 6 is built on top of Spring Boot 3.x and Hibernate 6, which brings:

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

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Grails · GORM

Last updated: April 5, 2026