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.
Table of Contents
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
| Annotation | Package | What It Generates |
|---|---|---|
@ToString | groovy.transform | A readable toString() method |
@EqualsAndHashCode | groovy.transform | equals() and hashCode() methods |
@TupleConstructor | groovy.transform | Positional-argument constructor |
@MapConstructor | groovy.transform | Map-based constructor |
@Canonical | groovy.transform | Combines @ToString, @EqualsAndHashCode, @TupleConstructor |
@Immutable | groovy.transform | Immutable class with defensive copies |
@Builder | groovy.transform.builder | Builder pattern for object construction |
@Delegate | groovy.lang | Delegates method calls to a contained object |
@Singleton | groovy.lang | Thread-safe singleton pattern |
@Sortable | groovy.transform | Comparable implementation with multi-field sorting |
@Memoized | groovy.transform | Caches method return values |
@Lazy | groovy.lang | Lazy 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 vs Local AST Transformations
Groovy supports two types of AST transformations:
| Aspect | Local (Annotation-based) | Global |
|---|---|---|
| Trigger | Annotation on class/method/field | Applied to all compiled code |
| Scope | Only annotated elements | All source files |
| Registration | Automatic via annotation | META-INF/services file |
| Examples | @ToString, @Immutable, @Builder | Custom logging, security checks |
| Ease of use | Simple – just add annotation | Requires packaging as a JAR |
| Common use | Day-to-day development | Framework/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
@Canonicalas 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
@Memoizedover manual caching – it handles thread safety and LRU eviction - Use
@Lazyfor expensive field initialization that may not always be needed
DON’T:
- Overuse AST transformations – if your class has 10 annotations, reconsider your design
- Use
@Memoizedon methods with side effects – caching prevents repeated execution - Forget that
@Canonicalmakes mutable objects that look like value types – consider@Immutablefor true value types - Rely on
@Singletonin 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
@Canonicalis the go-to annotation for data classes (combines @ToString, @EqualsAndHashCode, @TupleConstructor)@Delegateimplements composition-over-inheritance with zero boilerplate@Memoizedadds LRU caching to any method;@Lazydefers expensive field initialization- Always exclude sensitive fields from
@ToStringand 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.
Related Posts
Previous in Series: Groovy Traits – Reusable Behavior Composition
Next in Series: Groovy @Immutable – Create Immutable Objects
Related Topics You Might Like:
- Groovy Traits – Reusable Behavior Composition
- Groovy Categories and Mixins
- Groovy @Immutable – Create Immutable Objects
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment