Groovy AST Transformations – Compile-Time Code Generation with 10+ Examples

Groovy AST transformations for compile-time code generation. 10+ examples covering @ToString, @EqualsAndHashCode, @Canonical, @Delegate, and custom ASTs.

“The best code is code you never write. AST transformations generate boilerplate at compile time so you can focus on what matters.”

Groovy Metaprogramming Philosophy

Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate to Advanced | Reading Time: 20 minutes

Every developer knows the pain of writing toString(), equals(), hashCode(), constructors, and getters/setters for every class. It’s tedious, error-prone, and adds hundreds of lines that obscure the real logic. Groovy AST transformations eliminate this boilerplate by generating code at compile time – automatically, correctly, and consistently.

AST stands for Abstract Syntax Tree – the internal representation of your code that the compiler works with. AST transformations modify this tree before compilation finishes, injecting methods, fields, and even entire class structures. The result? You write a one-line annotation, and the compiler generates dozens of lines of bytecode for you.

If you’ve been following along from our Groovy Traits post, you’ve already seen how Groovy enables flexible code reuse. AST transformations take this further – while traits compose behavior at the class level, AST transformations modify classes at the compiler level. Together with Categories and Mixins, they form Groovy’s metaprogramming toolkit.

According to the official Groovy documentation on compile-time metaprogramming, AST transformations allow developers to hook into the compilation process and modify the AST before bytecode generation, providing a powerful mechanism for code generation and class manipulation.

What Are AST Transformations?

An AST transformation is a piece of code that runs during compilation and modifies the syntax tree of your program. Groovy ships with dozens of built-in transformations that handle common patterns. You apply them using annotations, and the compiler does the rest.

Key concepts:

  • Local transformations: Triggered by annotations on specific classes, methods, or fields (e.g., @ToString, @Immutable)
  • Global transformations: Applied to all code during compilation, registered via META-INF services
  • Compile phases: Transformations run at specific phases – INITIALIZATION, PARSING, CONVERSION, SEMANTIC_ANALYSIS, CANONICALIZATION, INSTRUCTION_SELECTION, CLASS_GENERATION, OUTPUT, FINALIZATION
  • Zero runtime overhead: Unlike runtime metaprogramming, AST transformations add no performance cost at execution time
  • IDE friendly: Most IDEs understand AST-generated code and provide proper autocomplete and navigation

Think of AST transformations as a code preprocessor with the full power of the compiler. They see your classes, their fields, methods, and annotations, and they can add, modify, or remove any element before the final bytecode is produced.

Quick Reference Table

AnnotationPackageWhat It Generates
@ToStringgroovy.transformA readable toString() method
@EqualsAndHashCodegroovy.transformequals() and hashCode() methods
@TupleConstructorgroovy.transformPositional-argument constructor
@MapConstructorgroovy.transformMap-based constructor
@Canonicalgroovy.transformCombines @ToString, @EqualsAndHashCode, @TupleConstructor
@Immutablegroovy.transformImmutable class with defensive copies
@Buildergroovy.transform.builderBuilder pattern for object construction
@Delegategroovy.langDelegates method calls to a contained object
@Singletongroovy.langThread-safe singleton pattern
@Sortablegroovy.transformComparable implementation with multi-field sorting
@Memoizedgroovy.transformCaches method return values
@Lazygroovy.langLazy initialization of a field

10 Practical Examples

Example 1: @ToString – Readable Object Representation

What we’re doing: Using @ToString to automatically generate a human-readable toString() method.

Example 1: @ToString

import groovy.transform.ToString

// Basic @ToString
@ToString
class User {
    String name
    int age
    String email
}

println new User(name: 'Alice', age: 30, email: 'alice@example.com')

// With options
@ToString(includeNames = true, includePackage = false)
class Product {
    String name
    double price
    String category
}

println new Product(name: 'Laptop', price: 999.99, category: 'Electronics')

// Exclude sensitive fields
@ToString(excludes = ['password', 'ssn'], includeNames = true)
class Account {
    String username
    String password
    String ssn
    String role
}

println new Account(username: 'admin', password: 'secret123', ssn: '123-45-6789', role: 'ADMIN')

// Include super class
@ToString(includeSuper = true, includeNames = true)
class Admin extends User {
    String department
}

println new Admin(name: 'Bob', age: 45, email: 'bob@co.com', department: 'IT')

Output

User(Alice, 30, alice@example.com)
Product(name:Laptop, price:999.99, category:Electronics)
Account(username:admin, role:ADMIN)
Admin(name:Bob, age:45, email:bob@co.com, department:IT, super:User(Bob, 45, bob@co.com))

What happened here: The @ToString annotation generates a toString() method at compile time. Without any options, it lists all field values in order. The includeNames option adds field names for readability. The excludes parameter is critical for security – never expose passwords or personal identifiers in logs. And includeSuper includes the parent class’s toString output. All of this is generated at compile time – no reflection, no runtime cost.

Example 2: @EqualsAndHashCode – Correct Equality

What we’re doing: Generating proper equals() and hashCode() methods automatically.

Example 2: @EqualsAndHashCode

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString

// Basic - uses all fields
@EqualsAndHashCode
@ToString(includeNames = true)
class Point {
    int x
    int y
}

def p1 = new Point(x: 10, y: 20)
def p2 = new Point(x: 10, y: 20)
def p3 = new Point(x: 30, y: 40)

println "p1 == p2: ${p1 == p2}"
println "p1 == p3: ${p1 == p3}"
println "p1.hashCode() == p2.hashCode(): ${p1.hashCode() == p2.hashCode()}"

// Works correctly in Sets and as Map keys
def points = [p1, p2, p3] as Set
println "Set size (no duplicates): ${points.size()}"

// Include only specific fields
@EqualsAndHashCode(includes = ['isbn'])
@ToString(includeNames = true)
class Book {
    String isbn
    String title
    String author
    double price
}

def book1 = new Book(isbn: '978-0-13-468599-1', title: 'Groovy in Action', author: 'Koenig', price: 49.99)
def book2 = new Book(isbn: '978-0-13-468599-1', title: 'Groovy in Action 2nd Ed', author: 'Koenig', price: 59.99)
println "Same ISBN, equal: ${book1 == book2}"

// Cache hashCode for performance
@EqualsAndHashCode(cache = true)
class CachedPoint {
    int x
    int y
}

def cp = new CachedPoint(x: 5, y: 10)
println "Hash (call 1): ${cp.hashCode()}"
println "Hash (call 2): ${cp.hashCode()}"  // Returns cached value

Output

p1 == p2: true
p1 == p3: false
p1.hashCode() == p2.hashCode(): true
Set size (no duplicates): 2
Same ISBN, equal: true
Hash (call 1): 371
Hash (call 2): 371

What happened here: Writing correct equals() and hashCode() methods by hand is notoriously tricky – you need null checks, type checks, field-by-field comparison, and a consistent hash code algorithm. @EqualsAndHashCode handles all of this correctly. The includes parameter lets you base equality on a natural key (like ISBN) instead of all fields. The cache option stores the hash code after the first computation – useful for objects used heavily in HashMaps or HashSets.

Example 3: @TupleConstructor – Positional Constructors

What we’re doing: Generating a constructor that takes arguments in field-declaration order.

Example 3: @TupleConstructor

import groovy.transform.TupleConstructor
import groovy.transform.ToString

@TupleConstructor
@ToString(includeNames = true)
class Color {
    int red
    int green
    int blue
}

// Positional constructor
def red = new Color(255, 0, 0)
println "Red: ${red}"

// Partial arguments (remaining get defaults)
def black = new Color()
println "Black: ${black}"

def halfBlue = new Color(0, 0, 128)
println "Half Blue: ${halfBlue}"

// Still works with map constructor
def green = new Color(red: 0, green: 255, blue: 0)
println "Green: ${green}"

// With defaults and includes
@TupleConstructor(includes = ['host', 'port'], defaults = true)
@ToString(includeNames = true)
class ServerConfig {
    String host = 'localhost'
    int port = 8080
    boolean ssl = false
    int maxConnections = 100
}

def default1 = new ServerConfig()
println "Default: ${default1}"

def custom = new ServerConfig('192.168.1.1', 443)
println "Custom: ${custom}"

// Force properties with pre/post conditions
@TupleConstructor(pre = { assert name?.trim() : 'Name is required' })
@ToString(includeNames = true)
class Employee {
    String name
    String department
}

def emp = new Employee('Alice', 'Engineering')
println "Employee: ${emp}"

try {
    new Employee('', 'HR')
} catch (AssertionError e) {
    println "Validation failed: ${e.message}"
}

Output

Red: Color(red:255, green:0, blue:0)
Black: Color(red:0, green:0, blue:0)
Half Blue: Color(red:0, green:0, blue:128)
Green: Color(red:0, green:255, blue:0)
Default: ServerConfig(host:localhost, port:8080, ssl:false, maxConnections:100)
Custom: ServerConfig(host:192.168.1.1, port:443, ssl:false, maxConnections:100)
Employee: Employee(name:Alice, department:Engineering)
Validation failed: Name is required. Expression: name?.trim(). Values: name =

What happened here: @TupleConstructor generates a constructor where arguments follow the field declaration order. It’s perfect when you want concise object creation without naming every parameter. The defaults option lets you omit trailing arguments, and the pre closure runs validation before assignment. The map constructor still works alongside the tuple constructor, so you get the best of both worlds.

Example 4: @Canonical – The All-in-One

What we’re doing: Using @Canonical to combine @ToString, @EqualsAndHashCode, and @TupleConstructor in one annotation.

Example 4: @Canonical

import groovy.transform.Canonical

@Canonical
class Address {
    String street
    String city
    String state
    String zip
}

// Tuple constructor
def addr1 = new Address('123 Main St', 'Springfield', 'IL', '62701')
println "Addr1: ${addr1}"

// Map constructor
def addr2 = new Address(city: 'Chicago', state: 'IL', zip: '60601', street: '456 Lake Dr')
println "Addr2: ${addr2}"

// Equals and hashCode work
def addr3 = new Address('123 Main St', 'Springfield', 'IL', '62701')
println "addr1 == addr3: ${addr1 == addr3}"

// Use in collections
def addresses = [addr1, addr2, addr3] as Set
println "Unique addresses: ${addresses.size()}"

// Canonical with customization
@Canonical(excludes = ['id'], includes = ['name', 'email'])
class Contact {
    long id
    String name
    String email
    String phone
}

def c1 = new Contact(name: 'Alice', email: 'alice@test.com', id: 1, phone: '555-1234')
def c2 = new Contact(name: 'Alice', email: 'alice@test.com', id: 2, phone: '555-5678')
println "c1: ${c1}"
println "c1 == c2 (id differs): ${c1 == c2}"

Output

Addr1: Address(123 Main St, Springfield, IL, 62701)
Addr2: Address(456 Lake Dr, Chicago, IL, 60601)
addr1 == addr3: true
Unique addresses: 2
c1: Contact(Alice, alice@test.com)
c1 == c2 (id differs): true

What happened here: @Canonical is the Swiss Army knife of AST transformations – it applies three annotations at once. One line gives you a complete value-type class with proper toString, equality, hashing, and constructors. The excludes and includes options pass through to all three underlying annotations. This is usually the first annotation experienced Groovy developers reach for.

Example 5: @Delegate – Composition Made Effortless

What we’re doing: Using @Delegate to automatically forward method calls to a contained object.

Example 5: @Delegate

import groovy.transform.ToString

// Delegate to a List
class TaskList {
    @Delegate
    List<String> tasks = []

    String summary() {
        "TaskList: ${size()} tasks, ${tasks.findAll { it.startsWith('[DONE]') }.size()} completed"
    }
}

def myTasks = new TaskList()
myTasks.add('Write blog post')
myTasks.add('Review PR')
myTasks.add('[DONE] Fix bug #42')
myTasks << 'Deploy to staging'

println "Tasks: ${myTasks}"
println "Size: ${myTasks.size()}"
println "First: ${myTasks.first()}"
println myTasks.summary()

println "\nIncomplete:"
myTasks.findAll { !it.startsWith('[DONE]') }.each { println "  - ${it}" }

// Delegate to multiple objects
class SmartDevice {
    @Delegate
    Speaker speaker = new Speaker()

    @Delegate
    Display display = new Display()

    String name
}

class Speaker {
    int volume = 50

    void playSound(String sound) {
        println "Playing '${sound}' at volume ${volume}"
    }

    void setVolume(int v) {
        volume = Math.max(0, Math.min(100, v))
        println "Volume set to ${volume}"
    }
}

class Display {
    int brightness = 70

    void showText(String text) {
        println "Display: ${text} (brightness: ${brightness}%)"
    }

    void setBrightness(int b) {
        brightness = Math.max(0, Math.min(100, b))
        println "Brightness set to ${brightness}%"
    }
}

def device = new SmartDevice(name: 'HomePod')
device.playSound('doorbell.mp3')
device.setVolume(80)
device.showText('Welcome Home!')
device.setBrightness(90)

Output

Tasks: [Write blog post, Review PR, [DONE] Fix bug #42, Deploy to staging]
Size: 4
First: Write blog post
TaskList: 4 tasks, 1 completed

Incomplete:
  - Write blog post
  - Review PR
  - Deploy to staging
Playing 'doorbell.mp3' at volume 50
Volume set to 80
Display: Welcome Home! (brightness: 70%)
Brightness set to 90%

What happened here: @Delegate generates forwarding methods for every public method of the delegated object. The TaskList class gets all List methods (add, size, first, etc.) without writing wrapper methods manually. The SmartDevice shows delegation to multiple objects – it acts as both a Speaker and a Display. This is the composition-over-inheritance pattern, implemented with zero boilerplate. If you’ve used Groovy traits for behavior composition, @Delegate is the alternative when you want to wrap existing objects.

Example 6: @Singleton – Thread-Safe Singletons

What we’re doing: Creating a singleton class with thread-safe initialization using @Singleton.

Example 6: @Singleton

// Basic singleton
@Singleton
class ConfigManager {
    Map settings = [
        'app.name': 'MyApp',
        'app.version': '2.0',
        'app.debug': 'false'
    ]

    String get(String key) {
        settings[key]
    }

    void set(String key, String value) {
        settings[key] = value
    }
}

// Access via .instance
def config = ConfigManager.instance
println "App name: ${config.get('app.name')}"
config.set('app.debug', 'true')
println "Debug: ${config.get('app.debug')}"

// Same instance everywhere
def config2 = ConfigManager.instance
println "Same instance: ${config.is(config2)}"
println "Debug from config2: ${config2.get('app.debug')}"

// Cannot create with new
try {
    new ConfigManager()
} catch (RuntimeException e) {
    println "Cannot instantiate: ${e.message}"
}

// Lazy singleton (created on first access)
@Singleton(lazy = true, strict = false)
class DatabasePool {
    List connections = []

    DatabasePool() {
        println "DatabasePool initialized!"
        5.times { connections << "Connection-${it}" }
    }

    String getConnection() {
        connections ? connections.pop() : 'No connections available'
    }
}

println "\nBefore first access..."
println "Getting pool..."
def pool = DatabasePool.instance
println "Connection: ${pool.getConnection()}"
println "Connection: ${pool.getConnection()}"

Output

App name: MyApp
Debug: true
Same instance: true
Debug from config2: true
Cannot instantiate: Can't instantiate singleton ConfigManager. Use ConfigManager.instance

Before first access...
Getting pool...
DatabasePool initialized!
Connection: Connection-0
Connection: Connection-1

What happened here: @Singleton makes the constructor private and creates a static instance field. Any attempt to use new throws an exception. The lazy = true option delays initialization until the first access – notice that “DatabasePool initialized!” only prints when we actually call DatabasePool.instance. The generated code is thread-safe by default, using double-checked locking internally.

Example 7: @Sortable – Automatic Comparable Implementation

What we’re doing: Making a class sortable by multiple fields with @Sortable.

Example 7: @Sortable

import groovy.transform.Sortable
import groovy.transform.ToString

@Sortable
@ToString(includeNames = true)
class Student {
    String lastName
    String firstName
    double gpa
}

def students = [
    new Student(firstName: 'Charlie', lastName: 'Brown', gpa: 3.5),
    new Student(firstName: 'Alice', lastName: 'Brown', gpa: 3.8),
    new Student(firstName: 'Bob', lastName: 'Adams', gpa: 3.2),
    new Student(firstName: 'Diana', lastName: 'Clark', gpa: 3.9),
    new Student(firstName: 'Alice', lastName: 'Adams', gpa: 3.7)
]

println "Default sort (lastName, firstName, gpa):"
students.sort().each { println "  ${it}" }

// Sort by specific field using generated comparators
println "\nBy GPA (ascending):"
students.sort(false, Student.comparatorByGpa()).each { println "  ${it}" }

println "\nBy firstName:"
students.sort(false, Student.comparatorByFirstName()).each { println "  ${it}" }

// Sort only by specific fields
@Sortable(includes = ['priority', 'dueDate'])
@ToString(includeNames = true)
class Task {
    String title
    int priority
    Date dueDate
}

def tasks = [
    new Task(title: 'Bug fix', priority: 1, dueDate: Date.parse('yyyy-MM-dd', '2026-03-15')),
    new Task(title: 'Feature', priority: 2, dueDate: Date.parse('yyyy-MM-dd', '2026-03-10')),
    new Task(title: 'Hotfix', priority: 1, dueDate: Date.parse('yyyy-MM-dd', '2026-03-08')),
    new Task(title: 'Refactor', priority: 3, dueDate: Date.parse('yyyy-MM-dd', '2026-03-20'))
]

println "\nTasks by priority, then dueDate:"
tasks.sort().each { println "  ${it}" }

Output

Default sort (lastName, firstName, gpa):
  Student(lastName:Adams, firstName:Alice, gpa:3.7)
  Student(lastName:Adams, firstName:Bob, gpa:3.2)
  Student(lastName:Brown, firstName:Alice, gpa:3.8)
  Student(lastName:Brown, firstName:Charlie, gpa:3.5)
  Student(lastName:Clark, firstName:Diana, gpa:3.9)

By GPA (ascending):
  Student(lastName:Adams, firstName:Bob, gpa:3.2)
  Student(lastName:Brown, firstName:Charlie, gpa:3.5)
  Student(lastName:Adams, firstName:Alice, gpa:3.7)
  Student(lastName:Brown, firstName:Alice, gpa:3.8)
  Student(lastName:Clark, firstName:Diana, gpa:3.9)

By firstName:
  Student(lastName:Adams, firstName:Alice, gpa:3.7)
  Student(lastName:Brown, firstName:Alice, gpa:3.8)
  Student(lastName:Adams, firstName:Bob, gpa:3.2)
  Student(lastName:Brown, firstName:Charlie, gpa:3.5)
  Student(lastName:Clark, firstName:Diana, gpa:3.9)

Tasks by priority, then dueDate:
  Task(title:Hotfix, priority:1, dueDate:Sun Mar 08 00:00:00 UTC 2026)
  Task(title:Bug fix, priority:1, dueDate:Sun Mar 15 00:00:00 UTC 2026)
  Task(title:Feature, priority:2, dueDate:Tue Mar 10 00:00:00 UTC 2026)
  Task(title:Refactor, priority:3, dueDate:Fri Mar 20 00:00:00 UTC 2026)

What happened here: @Sortable implements Comparable using all fields in declaration order. It also generates static comparatorByFieldName() methods for each field, so you can sort by any individual field. The includes parameter restricts which fields participate in the default sort order. Writing a multi-field compareTo() method by hand is tedious and error-prone – @Sortable gets it right every time.

Example 8: @Memoized – Automatic Method Caching

What we’re doing: Caching expensive method results with @Memoized so repeated calls return instantly.

Example 8: @Memoized

import groovy.transform.Memoized

class MathHelper {

    @Memoized
    long fibonacci(int n) {
        if (n <= 1) return n
        fibonacci(n - 1) + fibonacci(n - 2)
    }

    @Memoized
    BigInteger factorial(int n) {
        if (n <= 1) return 1G
        n * factorial(n - 1)
    }

    @Memoized(maxCacheSize = 100)
    boolean isPrime(int n) {
        if (n < 2) return false
        (2..Math.sqrt(n)).every { n % it != 0 }
    }
}

def math = new MathHelper()

// Fibonacci - without memoization, fib(40) takes seconds
def start = System.currentTimeMillis()
println "fib(10) = ${math.fibonacci(10)}"
println "fib(20) = ${math.fibonacci(20)}"
println "fib(30) = ${math.fibonacci(30)}"
println "fib(40) = ${math.fibonacci(40)}"
def elapsed = System.currentTimeMillis() - start
println "Time: ${elapsed}ms (fast due to memoization!)"

// Factorial
println "\n10! = ${math.factorial(10)}"
println "20! = ${math.factorial(20)}"

// Primes with limited cache
println "\nPrimes up to 30:"
def primes = (2..30).findAll { math.isPrime(it) }
println primes

Output

fib(10) = 55
fib(20) = 6765
fib(30) = 832040
fib(40) = 102334155
Time: 12ms (fast due to memoization!)

10! = 3628800
20! = 2432902008176640000

Primes up to 30:
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

What happened here: @Memoized wraps the method with a cache that stores the return value for each unique set of arguments. The recursive Fibonacci function would normally take exponential time for fib(40), but memoization reduces it to linear time because each value is computed only once. The maxCacheSize option prevents unbounded memory growth – when the cache is full, oldest entries are evicted. This is an LRU (Least Recently Used) cache built right into the language.

Example 9: @Lazy – Deferred Field Initialization

What we’re doing: Using @Lazy to delay expensive field initialization until the first access.

Example 9: @Lazy

class ResourceManager {

    @Lazy
    List<String> configEntries = {
        println "  [Loading config from disk...]"
        ['db.host=localhost', 'db.port=5432', 'app.name=MyApp']
    }()

    @Lazy
    Map<String, Integer> lookupTable = {
        println "  [Building lookup table...]"
        (1..1000).collectEntries { [(it.toString()): it * it] }
    }()

    @Lazy(soft = true)
    byte[] largeBuffer = {
        println "  [Allocating 1MB buffer...]"
        new byte[1024 * 1024]
    }()

    String status() {
        "ResourceManager ready"
    }
}

println "Creating ResourceManager..."
def rm = new ResourceManager()
println rm.status()
println "(Nothing loaded yet!)\n"

println "Accessing config..."
println "Config entries: ${rm.configEntries.size()}"
println ""

println "Accessing config again (cached)..."
println "Config entries: ${rm.configEntries.size()}"
println ""

println "Accessing lookup table..."
println "Lookup for '42': ${rm.lookupTable['42']}"
println ""

println "Accessing buffer..."
println "Buffer size: ${rm.largeBuffer.length} bytes"

Output

Creating ResourceManager...
ResourceManager ready
(Nothing loaded yet!)

Accessing config...
  [Loading config from disk...]
Config entries: 3

Accessing config again (cached)...
Config entries: 3

Accessing lookup table...
  [Building lookup table...]
Lookup for '42': 1764

Accessing buffer...
  [Allocating 1MB buffer...]
Buffer size: 1048576 bytes

What happened here: @Lazy delays field initialization until the getter is first called. Notice that when we create the ResourceManager and call status(), nothing is loaded. Each field initializes only when accessed, and subsequent accesses use the cached value. The soft = true option uses a SoftReference, allowing the JVM to garbage-collect the value under memory pressure and re-initialize it later. This is perfect for large buffers or caches that can be rebuilt.

Example 10: @Log – Automatic Logger Injection

What we’re doing: Injecting a logger field automatically using @Log family annotations.

Example 10: @Log

import groovy.util.logging.Log

@Log
class OrderService {

    void placeOrder(String product, int quantity) {
        log.info "Placing order: ${product} x${quantity}"

        if (quantity <= 0) {
            log.warning "Invalid quantity: ${quantity}"
            return
        }

        def total = calculateTotal(product, quantity)
        log.info "Order total: \$${total}"

        processPayment(total)
        log.info "Order completed successfully"
    }

    private double calculateTotal(String product, int quantity) {
        def prices = [laptop: 999.99, phone: 699.99, tablet: 499.99]
        def price = prices[product.toLowerCase()] ?: 0.0
        log.fine "Price lookup: ${product} = \$${price}"
        price * quantity
    }

    private void processPayment(double amount) {
        log.info "Processing payment of \$${amount}"
        // Payment logic here
    }
}

def service = new OrderService()
service.placeOrder('Laptop', 2)
println "---"
service.placeOrder('Phone', 0)

// The log field is a java.util.logging.Logger
println "\nLogger class: ${OrderService.getDeclaredField('log').type.simpleName}"

Output

Mar 08, 2026 2:32:07 PM OrderService placeOrder
INFO: Placing order: Laptop x2
Mar 08, 2026 2:32:07 PM OrderService placeOrder
INFO: Order total: $1999.98
Mar 08, 2026 2:32:07 PM OrderService processPayment
INFO: Processing payment of $1999.98
Mar 08, 2026 2:32:07 PM OrderService placeOrder
INFO: Order completed successfully
---
Mar 08, 2026 2:32:07 PM OrderService placeOrder
INFO: Placing order: Phone x0
Mar 08, 2026 2:32:07 PM OrderService placeOrder
WARNING: Invalid quantity: 0

Logger class: Logger

What happened here: The @Log annotation injects a log field of type java.util.logging.Logger into the class at compile time. You don’t need to declare it – just use it. Groovy also provides @Log4j, @Log4j2, @Slf4j, and @Commons for other logging frameworks. The annotation also wraps log statements in isLoggable() checks automatically, preventing expensive string interpolation when the log level is disabled.

Example 11: @MapConstructor – Named Parameter Constructor

What we’re doing: Generating a constructor that accepts a Map for named parameters with validation.

Example 11: @MapConstructor

import groovy.transform.MapConstructor
import groovy.transform.ToString

@MapConstructor
@ToString(includeNames = true)
class DatabaseConfig {
    String host = 'localhost'
    int port = 5432
    String database = 'mydb'
    String username = 'admin'
    int maxPoolSize = 10
    boolean ssl = false
}

// Default values
def defaultConfig = new DatabaseConfig()
println "Default: ${defaultConfig}"

// Override specific fields
def prodConfig = new DatabaseConfig(
    host: 'db.production.com',
    port: 5433,
    database: 'prod_db',
    ssl: true,
    maxPoolSize: 50
)
println "Prod: ${prodConfig}"

// Partial override
def testConfig = new DatabaseConfig(database: 'test_db')
println "Test: ${testConfig}"

// Build from an existing map
def configMap = [host: 'staging.com', database: 'stage_db', ssl: true]
def stageConfig = new DatabaseConfig(configMap)
println "Stage: ${stageConfig}"

// With pre/post conditions
@MapConstructor(pre = {
    assert args.name : 'name is required'
    assert args.age >= 0 : 'age must be non-negative'
}, post = {
    this.name = this.name.trim().capitalize()
})
@ToString(includeNames = true)
class Person {
    String name
    int age
}

def p = new Person(name: '  alice  ', age: 30)
println "Person: ${p}"

Output

Default: DatabaseConfig(host:localhost, port:5432, database:mydb, username:admin, maxPoolSize:10, ssl:false)
Prod: DatabaseConfig(host:db.production.com, port:5433, database:prod_db, username:admin, maxPoolSize:50, ssl:true)
Test: DatabaseConfig(host:localhost, port:5432, database:test_db, username:admin, maxPoolSize:10, ssl:false)
Stage: DatabaseConfig(host:staging.com, port:5432, database:stage_db, username:admin, maxPoolSize:10, ssl:true)
Person: Person(name:Alice, age:30)

What happened here: @MapConstructor explicitly generates a map-based constructor. While Groovy classes already get a map constructor by default, @MapConstructor lets you add pre and post validation, control which fields are included, and ensure the constructor works correctly with inheritance. The post closure runs after field assignment, allowing normalization like trimming and capitalizing the name.

The following diagram shows the Groovy compilation phases and where different types of AST transformations hook in:

Global AST Transforms
(no annotation needed)

@ToString @Canonical
@TupleConstructor @Builder

@TypeChecked
@CompileStatic @Immutable

@Sortable @Delegate @Lazy

INITIALIZATION

PARSING

CONVERSION

SEMANTIC ANALYSIS

CANONICALIZATION

INSTRUCTION SELECTION

CLASS GENERATION

OUTPUT

FINALIZATION

Global Transforms

Local AST Transforms
(annotation-driven)

Semantic Transforms

Canonicalization Transforms

Groovy Compilation Phases – Where AST Transforms Run

Global vs Local AST Transformations

Groovy supports two types of AST transformations:

AspectLocal (Annotation-based)Global
TriggerAnnotation on class/method/fieldApplied to all compiled code
ScopeOnly annotated elementsAll source files
RegistrationAutomatic via annotationMETA-INF/services file
Examples@ToString, @Immutable, @BuilderCustom logging, security checks
Ease of useSimple – just add annotationRequires packaging as a JAR
Common useDay-to-day developmentFramework/library development

For most developers, local (annotation-based) transformations are what you’ll use daily. Global transformations are typically created by framework authors – for example, Grails uses global AST transformations to add persistence methods to domain classes. Unless you’re building a framework, you’ll rarely need to write a global transformation.

Combining Multiple AST Annotations

AST annotations work together naturally. Here’s a real-world example combining several annotations on a single class:

Combining Multiple AST Annotations

import groovy.transform.*
import groovy.transform.builder.Builder

@ToString(includeNames = true, excludes = ['password'])
@EqualsAndHashCode(includes = ['email'])
@TupleConstructor(includes = ['email', 'name'])
@Sortable(includes = ['name'])
class UserAccount {
    String email
    String name
    String password
    Date createdAt = new Date()
    boolean active = true
}

def users = [
    new UserAccount('charlie@test.com', 'Charlie'),
    new UserAccount('alice@test.com', 'Alice'),
    new UserAccount('bob@test.com', 'Bob'),
]

// toString works (no password)
users.each { println it }

// Sorting works (by name)
println "\nSorted:"
users.sort().each { println "  ${it.name}" }

// Equality by email
def u1 = new UserAccount('same@test.com', 'First')
def u2 = new UserAccount('same@test.com', 'Second')
println "\nSame email, equal: ${u1 == u2}"

Output

UserAccount(email:charlie@test.com, name:Charlie, createdAt:Sun Mar 08 14:32:07 UTC 2026, active:true)
UserAccount(email:alice@test.com, name:Alice, createdAt:Sun Mar 08 14:32:07 UTC 2026, active:true)
UserAccount(email:bob@test.com, name:Bob, createdAt:Sun Mar 08 14:32:07 UTC 2026, active:true)

Sorted:
  Alice
  Bob
  Charlie

Same email, equal: true

Each annotation handles its own concern: @ToString manages display, @EqualsAndHashCode manages identity, @Sortable manages ordering, and @TupleConstructor manages construction. They compose cleanly because each modifies different parts of the AST.

Performance Benefits

AST transformations have a major advantage over runtime metaprogramming: zero runtime overhead. The code is generated at compile time, so the JVM sees regular bytecode with no reflection, no method interception, and no dynamic dispatch.

  • Compile-time generation: Code is generated once during compilation, not on every method call
  • No reflection: Generated methods are real bytecode methods – the JVM can inline and optimize them
  • Type safe: The compiler verifies generated code, catching errors early
  • JIT friendly: The JVM’s Just-In-Time compiler can optimize AST-generated code just like handwritten code

Compare this to runtime metaprogramming using methodMissing or invokeMethod, which adds overhead on every call. AST transformations are always the better choice when you know at compile time what code you need.

Best Practices

DO:

  • Use @Canonical as your default for data classes – it covers toString, equals, hashCode, and constructors
  • Always exclude sensitive fields (passwords, tokens) from @ToString
  • Use @EqualsAndHashCode(includes = ['naturalKey']) for entities with a business key
  • Prefer @Memoized over manual caching – it handles thread safety and LRU eviction
  • Use @Lazy for expensive field initialization that may not always be needed

DON’T:

  • Overuse AST transformations – if your class has 10 annotations, reconsider your design
  • Use @Memoized on methods with side effects – caching prevents repeated execution
  • Forget that @Canonical makes mutable objects that look like value types – consider @Immutable for true value types
  • Rely on @Singleton in environments where classloaders may create multiple instances (like some app servers)

Common Pitfalls

Pitfall 1: @EqualsAndHashCode on Mutable Fields

Mutable Fields Pitfall

import groovy.transform.EqualsAndHashCode

@EqualsAndHashCode
class MutableKey {
    String name
    int value
}

def map = [:]
def key = new MutableKey(name: 'test', value: 42)
map[key] = 'hello'
println "Before mutation: ${map[key]}"

// Mutating the key breaks the map!
key.value = 99
println "After mutation: ${map[key]}"  // null - hashCode changed!
println "Map not empty: ${map.size()}"
println "But key lookup fails!"

Output

Before mutation: hello
After mutation: null
Map not empty: 1
But key lookup fails!

If you use a mutable object as a HashMap key and then change a field that participates in hashCode(), the entry becomes unreachable. The map still holds it, but lookups use the new hash code, which points to a different bucket. Either use @Immutable or only include immutable fields in @EqualsAndHashCode.

Pitfall 2: @ToString with Circular References

Circular Reference Pitfall

import groovy.transform.ToString

@ToString
class Node {
    String name
    Node parent
    List<Node> children = []
}

def root = new Node(name: 'root')
def child = new Node(name: 'child', parent: root)
root.children << child

// Without protection, this would cause StackOverflowError
// @ToString handles it by detecting cycles

// Better approach: exclude the circular field
@ToString(excludes = ['parent'])
class SafeNode {
    String name
    SafeNode parent
    List<SafeNode> children = []
}

def safeRoot = new SafeNode(name: 'root')
def safeChild = new SafeNode(name: 'child', parent: safeRoot)
safeRoot.children << safeChild
println safeRoot

Output

SafeNode(root, [SafeNode(child, [])])

Circular references in @ToString can cause StackOverflowError. Always exclude back-references (like parent) from @ToString when you have bidirectional relationships. The same applies to @EqualsAndHashCode – circular fields should be excluded from equality checks too.

Conclusion

Groovy AST transformations are one of the language’s killer features. They eliminate boilerplate, prevent bugs, and make your classes more expressive – all with zero runtime cost. From basic annotations like @ToString and @EqualsAndHashCode to powerful patterns like @Delegate, @Memoized, and @Lazy, they cover the most common code generation needs.

We’ve covered the most important built-in transformations with practical examples. If you came here from our Groovy Traits post, you now have two flexible composition tools – traits for behavior composition and AST transformations for code generation. Together with Categories and Mixins, they form Groovy’s full metaprogramming toolkit.

For more details into the official transformation catalog, check the Groovy compile-time metaprogramming documentation. There are many more transformations we haven’t covered here – @Newify, @PackageScope, @AutoClone, @AutoExternalize, and more. Explore them as your needs grow.

Summary

  • AST transformations generate code at compile time with zero runtime overhead
  • @Canonical is the go-to annotation for data classes (combines @ToString, @EqualsAndHashCode, @TupleConstructor)
  • @Delegate implements composition-over-inheritance with zero boilerplate
  • @Memoized adds LRU caching to any method; @Lazy defers expensive field initialization
  • Always exclude sensitive fields from @ToString and circular references from @EqualsAndHashCode

If you also work with build tools, CI/CD pipelines, or cloud CLIs, check out Command Playground to practice 105+ CLI tools directly in your browser — no install needed.

Up next: Groovy @Immutable – Create Immutable Objects

Frequently Asked Questions

What are Groovy AST transformations?

Groovy AST transformations are compile-time code generation mechanisms that modify the Abstract Syntax Tree (AST) of your code before bytecode is produced. They allow you to add methods, fields, and behavior to classes using simple annotations like @ToString, @EqualsAndHashCode, and @Immutable – eliminating boilerplate code with zero runtime overhead.

What is the difference between @Canonical and @Immutable in Groovy?

@Canonical combines @ToString, @EqualsAndHashCode, and @TupleConstructor to create a complete mutable data class. @Immutable does the same but also makes all fields final, generates defensive copies of mutable fields (like collections and dates), and prevents any modification after construction. Use @Canonical for mutable classes and @Immutable for value objects.

Do AST transformations affect runtime performance?

No. AST transformations run during compilation and generate regular bytecode. At runtime, the generated code performs exactly like handwritten code – the JVM sees no difference. This is a major advantage over runtime metaprogramming techniques like methodMissing or invokeMethod, which add overhead on every method call.

What does @Delegate do in Groovy?

The @Delegate annotation implements the delegation pattern by automatically generating forwarding methods for all public methods of the delegated object. For example, if a class has a field @Delegate List items, the class gains all List methods (add, size, get, etc.) without writing wrapper methods. It’s a powerful way to compose objects without inheritance.

How do I write a custom AST transformation in Groovy?

To write a custom local AST transformation: 1) Create an annotation annotated with @GroovyASTTransformationClass, 2) Create a class implementing ASTTransformation that modifies the AST nodes, 3) Package both in a JAR. For global transformations, register via META-INF/services/org.codehaus.groovy.transform.ASTTransformation. The Groovy documentation provides detailed examples of both approaches.

Previous in Series: Groovy Traits – Reusable Behavior Composition

Next in Series: Groovy @Immutable – Create Immutable Objects

Related Topics You Might Like:

This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *