Groovy advanced AST transforms beyond @ToString and @Canonical. Part 2 covers @TupleConstructor, @MapConstructor, @Delegate, @Memoized, @AutoClone, @Sortable, @Newify, and combining multiple transforms.
“The best boilerplate is the boilerplate you never write. AST transformations are your compiler doing your chores.”
Last Updated: July 2026 | Tested on: Groovy 5.0.6, Java 17+ | Difficulty: Intermediate | Reading Time: 20 minutes
This is Part 2 of our AST transformations series. Part 1 covered the essentials – @ToString, @EqualsAndHashCode, @Canonical, @Immutable, and @Builder. Those are the transforms every Groovy developer learns first. This post covers the groovy advanced AST transforms that most developers never discover but that save serious time in production code: constructor generators, delegation, memoization, cloning, sorting, and powerful combinations of multiple transforms on a single class.
The official transforms reference lists over 30 code generation transforms. Part 1 covered the top 5. This post works through the next tier: @TupleConstructor and @MapConstructor for flexible object creation, @Delegate for composition over inheritance, @Memoized for automatic caching, @AutoClone for deep copying, @Sortable for comparable classes, and @Newify for alternative constructor syntax. Every example is self-contained with real output.
Make sure you have read Part 1 first – we build on those concepts here.
The diagram below shows how Groovy decides which generated method a call site actually runs when a class carries both @Sortable and @EqualsAndHashCode. It traces ==, .equals(), Set membership, and sort() to the exact generated method each one lands on, which is why == and equals can disagree on the same two objects:
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
What you’ll learn:
- How
@TupleConstructorand@MapConstructorgenerate constructors automatically - How
@Delegateimplements delegation without writing wrapper methods - How
@Memoizedcaches method results transparently - How
@AutoCloneand@AutoImplementhandle object copying and interface implementation - How
@Newifygives you alternative constructor syntax - How
@SortablegeneratesComparableimplementations - How
@EqualsAndHashCodeoptions let you fine-tune equality - How to combine multiple transforms for maximum effect
Quick Reference Table
| Transform | Purpose | Key Options |
|---|---|---|
@TupleConstructor | Positional constructor from properties | includes, excludes, includeSuperProperties, defaults |
@MapConstructor | Map-based constructor from properties | includes, excludes, noArg |
@Delegate | Delegate method calls to a field | interfaces, includes, excludes, deprecated |
@Memoized | Cache method return values | maxCacheSize, protectedCacheSize |
@AutoClone | Generate clone() method | style (CLONE, COPY_CONSTRUCTOR, SERIALIZATION) |
@AutoImplement | Generate default implementations for abstract methods | exception, code |
@Newify | Alternative constructor syntax | auto, class list |
@Sortable | Generate Comparable and comparators | includes, excludes |
@EqualsAndHashCode | Generate equals/hashCode | includes, excludes, callSuper, cache |
Table of Contents
Groovy Advanced AST Transforms: 12 Examples
Each example is self-contained. Copy the code into a .groovy file or the Groovy Shell and run it directly.
Example 1: @TupleConstructor Basics
What we’re doing: Generating a positional constructor automatically from class properties so you don’t write constructor boilerplate.
Example 1: @TupleConstructor Basics
import groovy.transform.TupleConstructor
import groovy.transform.ToString
@TupleConstructor
@ToString(includeNames = true)
class Employee {
String name
String department
int yearsOfService
boolean active = true
}
// Positional constructor - all fields
def emp1 = new Employee('Nirranjan', 'Engineering', 5, true)
println emp1
// Partial constructor - remaining fields get defaults
def emp2 = new Employee('Viraj', 'Marketing')
println emp2
// No-arg constructor is still available
def emp3 = new Employee()
emp3.name = 'Prathamesh'
println emp3
// Groovy named params still work with the no-arg constructor
def emp4 = new Employee(name: 'Prathamesh', department: 'HR', yearsOfService: 3)
println emp4
Output
Employee(name:Nirranjan, department:Engineering, yearsOfService:5, active:true) Employee(name:Viraj, department:Marketing, yearsOfService:0, active:true) Employee(name:Prathamesh, department:null, yearsOfService:0, active:true) Employee(name:Prathamesh, department:HR, yearsOfService:3, active:true)
What happened here: @TupleConstructor generated a constructor that accepts parameters in the same order as the properties are declared. The defaults option (true by default) means you can call the constructor with fewer arguments – missing fields get their default values (null for objects, 0 for primitives, or the initializer value like true for active). The no-arg constructor is preserved automatically, so Groovy’s named parameter syntax still works.
Example 2: @TupleConstructor with Includes and Excludes
What we’re doing: Controlling which properties appear in the generated constructor using includes and excludes.
Example 2: @TupleConstructor with Includes/Excludes
import groovy.transform.TupleConstructor
import groovy.transform.ToString
@TupleConstructor(includes = ['name', 'email'])
@ToString(includeNames = true)
class User {
String name
String email
Date createdAt = new Date()
String role = 'USER'
}
// Only name and email are constructor params
def user1 = new User('Nirranjan', 'nirranjan@example.com')
println user1
// createdAt and role use their defaults
println "Role: ${user1.role}"
println "Created: ${user1.createdAt != null}"
// Using excludes instead
@TupleConstructor(excludes = ['id', 'createdAt'])
@ToString(includeNames = true)
class Product {
long id = System.nanoTime()
String name
BigDecimal price
String category
Date createdAt = new Date()
}
def prod = new Product('Laptop', 999.99, 'Electronics')
println prod
println "ID assigned: ${prod.id > 0}"
Output
User(name:Nirranjan, email:nirranjan@example.com, createdAt:Wed Mar 11 10:15:30 UTC 2026, role:USER) Role: USER Created: true Product(id:1284729374829, name:Laptop, price:999.99, category:Electronics, createdAt:Wed Mar 11 10:15:30 UTC 2026) ID assigned: true
What happened here: The includes parameter limits the constructor to only the listed properties – createdAt and role aren’t constructor parameters, so they use their initializer values. The excludes parameter does the opposite – it removes specific properties from the constructor while including everything else. Use includes when you have few constructor params and many auto-set fields. Use excludes when most fields should be in the constructor but a few shouldn’t. The createdAt timestamp and the nanosecond-based id in the output come from new Date() and System.nanoTime(), so those exact values will be different every time you run this.
Example 3: @MapConstructor for Named Parameters
What we’re doing: Generating a constructor that accepts a Map argument, giving you explicit named-parameter construction with compile-time safety.
Example 3: @MapConstructor
import groovy.transform.MapConstructor
import groovy.transform.ToString
@MapConstructor
@ToString(includeNames = true)
class ServerConfig {
String host
int port
boolean ssl
int maxConnections = 100
String protocol = 'HTTP/2'
}
// Full map constructor
def config1 = new ServerConfig(host: 'api.example.com', port: 443, ssl: true)
println config1
// Only some fields - rest get defaults
def config2 = new ServerConfig(host: 'localhost', port: 8080, ssl: false)
println config2
println "Max connections: ${config2.maxConnections}"
println "Protocol: ${config2.protocol}"
// Combine with @TupleConstructor for both styles
import groovy.transform.TupleConstructor
@MapConstructor
@TupleConstructor
@ToString(includeNames = true)
class Point {
double x
double y
String label = 'unlabeled'
}
def p1 = new Point(3.0, 4.0) // tuple style
def p2 = new Point(x: 1.5, y: 2.5) // map style
println "Tuple: ${p1}"
println "Map: ${p2}"
Output
ServerConfig(host:api.example.com, port:443, ssl:true, maxConnections:100, protocol:HTTP/2) ServerConfig(host:localhost, port:8080, ssl:false, maxConnections:100, protocol:HTTP/2) Max connections: 100 Protocol: HTTP/2 Tuple: Point(x:3.0, y:4.0, label:unlabeled) Map: Point(x:1.5, y:2.5, label:unlabeled)
What happened here: @MapConstructor generates a constructor that takes a Map and sets properties by key name. This is different from Groovy’s default named-parameter trick (which uses the no-arg constructor + setters). With @MapConstructor, the map-based construction is a real constructor, which matters for frameworks that use reflection to find constructors. You can combine it with @TupleConstructor to support both positional and named styles on the same class.
Example 4: @Delegate for Composition Over Inheritance
What we’re doing: Using @Delegate to automatically forward method calls to an inner object, implementing the delegation pattern without writing wrapper methods.
Example 4: @Delegate
import groovy.transform.ToString
// A simple event log
class EventLog {
private List<String> entries = []
void log(String message) {
entries << "[${new Date().format('HH:mm:ss')}] ${message}"
}
List<String> getEntries() { entries.asImmutable() }
int size() { entries.size() }
void clear() { entries.clear() }
}
// UserService delegates logging to EventLog
class UserService {
@Delegate EventLog eventLog = new EventLog()
String createUser(String name) {
log("Creating user: ${name}")
return "user_${name.toLowerCase()}"
}
boolean deleteUser(String id) {
log("Deleting user: ${id}")
return true
}
}
def service = new UserService()
service.createUser('Nirranjan')
service.createUser('Viraj')
service.deleteUser('user_alice')
// These methods come from EventLog via @Delegate
println "Log entries: ${service.size()}"
service.entries.each { println " ${it}" }
// @Delegate with interfaces
interface Printable {
void prettyPrint()
}
class Report implements Printable {
String title
void prettyPrint() { println "=== ${title} ===" }
}
class Dashboard {
@Delegate(interfaces = true) Report mainReport = new Report(title: 'Q1 Sales')
}
def dash = new Dashboard()
dash.prettyPrint()
println "Dashboard is Printable: ${dash instanceof Printable}"
Output
Log entries: 3 [10:15:30] Creating user: Nirranjan [10:15:30] Creating user: Viraj [10:15:30] Deleting user: user_alice === Q1 Sales === Dashboard is Printable: true
What happened here: @Delegate generated forwarding methods on UserService for every public method in EventLog. You can call service.size() and service.entries directly – no wrapper code needed. With interfaces = true, the owning class also implements the delegate’s interfaces, so Dashboard becomes a Printable without explicitly declaring implements Printable. This is composition over inheritance, enforced by the compiler. The [HH:mm:ss] timestamps in the log come from new Date(), so they reflect the wall-clock time when you run the code.
Example 5: @Delegate with Includes and Excludes
What we’re doing: Selectively delegating only certain methods from the target object, keeping the rest private to the delegate.
Example 5: @Delegate with Includes/Excludes
class DatabaseConnection {
String url = 'jdbc:h2:mem:test'
def query(String sql) { "Results for: ${sql}" }
def execute(String sql) { "Executed: ${sql}" }
void close() { println "Connection closed" }
void reset() { println "Connection reset" }
String getStatus() { "CONNECTED" }
}
// Only expose query and execute - hide close, reset, and status
class ReadOnlyRepository {
@Delegate(includes = ['query', 'getStatus'])
DatabaseConnection conn = new DatabaseConnection()
}
def repo = new ReadOnlyRepository()
println repo.query('SELECT * FROM users')
println repo.status
// close() is NOT delegated - it stays hidden
try {
repo.close()
} catch (MissingMethodException e) {
println "Cannot call close(): ${e.message.split('\n')[0]}"
}
// Using excludes to hide dangerous methods
class SafeConnection {
@Delegate(excludes = ['close', 'reset'])
DatabaseConnection conn = new DatabaseConnection()
}
def safe = new SafeConnection()
println safe.query('SELECT 1')
println safe.execute('INSERT INTO log VALUES(1)')
println "Status: ${safe.status}"
Output
Results for: SELECT * FROM users CONNECTED Cannot call close(): No signature of method: close for class: ReadOnlyRepository is applicable for argument types: () values: [] Results for: SELECT 1 Executed: INSERT INTO log VALUES(1) Status: CONNECTED
What happened here: With includes, only the listed methods are forwarded – calling close() on ReadOnlyRepository throws a MissingMethodException because it was never delegated. With excludes, every method is forwarded except the listed ones. This lets you create safe wrappers that hide dangerous operations while exposing the rest of the API transparently. One small detail: since Groovy 5, the MissingMethodException text reads No signature of method: close for class: ReadOnlyRepository instead of the older ReadOnlyRepository.close() phrasing, so if you assert on that message, expect the newer wording on Groovy 5.0.6 (the version at the time of writing).
Example 6: @Memoized for Automatic Caching
What we’re doing: Caching expensive method results automatically so repeated calls with the same arguments return instantly.
Example 6: @Memoized
import groovy.transform.Memoized
class MathService {
int callCount = 0
@Memoized
long fibonacci(int n) {
callCount++
if (n <= 1) return n
return fibonacci(n - 1) + fibonacci(n - 2)
}
@Memoized
BigDecimal factorial(int n) {
callCount++
if (n <= 1) return 1G
return n * factorial(n - 1)
}
@Memoized(maxCacheSize = 5)
String formatNumber(int value, String locale) {
callCount++
return String.format(java.util.Locale.forLanguageTag(locale), "%,d", value)
}
}
def svc = new MathService()
// First call computes the result
def start = System.nanoTime()
println "fib(30) = ${svc.fibonacci(30)}"
def firstTime = (System.nanoTime() - start) / 1_000_000.0
// Second call returns cached result instantly
start = System.nanoTime()
println "fib(30) = ${svc.fibonacci(30)}"
def secondTime = (System.nanoTime() - start) / 1_000_000.0
println "First call: ${firstTime.round(2)}ms"
println "Second call: ${secondTime.round(2)}ms (cached)"
// Factorial with memoization
println "\n50! = ${svc.factorial(50)}"
println "50! again = ${svc.factorial(50)}"
// maxCacheSize limits the cache
(1..10).each { svc.formatNumber(it * 1000, 'en-US') }
println "\nformatNumber(5000, 'en-US') = ${svc.formatNumber(5000, 'en-US')}"
println "Total method invocations: ${svc.callCount}"
Output
fib(30) = 832040 fib(30) = 832040 First call: 12.45ms Second call: 0.02ms (cached) 50! = 30414093201713378043612608166064768844377641568960512000000000000 50! again = 30414093201713378043612608166064768844377641568960512000000000000 formatNumber(5000, 'en-US') = 5,000 Total method invocations: 92
What happened here: @Memoized wraps the method with a cache keyed on the arguments. The first call to fibonacci(30) does the actual computation; the second call returns the cached result in microseconds. For the recursive Fibonacci, memoization turns an exponential algorithm into a linear one because intermediate results are cached too. The maxCacheSize option limits memory usage – when the cache exceeds 5 entries, older entries are evicted. Use @Memoized for pure functions (same input always gives same output) that are called repeatedly with the same arguments. The exact millisecond timings above will differ on your machine and from run to run, but the pattern holds: the first call does real work and the cached call is dramatically faster.
Example 7: @AutoClone for Object Copying
What we’re doing: Generating a clone() method automatically with different cloning strategies.
Example 7: @AutoClone
import groovy.transform.AutoClone
import groovy.transform.AutoCloneStyle
import groovy.transform.ToString
// Default style: CLONE (calls super.clone() + clones fields)
@AutoClone
@ToString(includeNames = true)
class Address {
String street
String city
String zip
}
@AutoClone
@ToString(includeNames = true)
class Person {
String name
int age
Address address
}
def original = new Person(
name: 'Nirranjan',
age: 30,
address: new Address(street: '123 Main St', city: 'Springfield', zip: '62701')
)
def cloned = original.clone()
println "Original: ${original}"
println "Cloned: ${cloned}"
println "Same object? ${original.is(cloned)}"
// Modify clone - original is unaffected
cloned.name = 'Viraj'
cloned.age = 25
println "\nAfter modifying clone:"
println "Original: ${original}"
println "Cloned: ${cloned}"
// COPY_CONSTRUCTOR style
@AutoClone(style = AutoCloneStyle.COPY_CONSTRUCTOR)
@ToString(includeNames = true)
class Config {
String env
int timeout
List<String> features
}
def cfg1 = new Config(env: 'production', timeout: 30, features: ['auth', 'cache'])
def cfg2 = cfg1.clone()
cfg2.features << 'logging'
println "\nCopy constructor clone:"
println "Original features: ${cfg1.features}"
println "Cloned features: ${cfg2.features}"
Output
Original: Person(name:Nirranjan, age:30, address:Address(street:123 Main St, city:Springfield, zip:62701)) Cloned: Person(name:Nirranjan, age:30, address:Address(street:123 Main St, city:Springfield, zip:62701)) Same object? false After modifying clone: Original: Person(name:Nirranjan, age:30, address:Address(street:123 Main St, city:Springfield, zip:62701)) Cloned: Person(name:Viraj, age:25, address:Address(street:123 Main St, city:Springfield, zip:62701)) Copy constructor clone: Original features: [auth, cache] Cloned features: [auth, cache, logging]
What happened here: @AutoClone generates a clone() method and makes the class implement Cloneable. The default CLONE style uses super.clone() and then clones each field that is also Cloneable. The COPY_CONSTRUCTOR style creates a copy constructor instead, which is often cleaner for classes with mutable collections. Note that the features list in the copy constructor example is a separate list, so adding to the clone doesn’t affect the original.
Example 8: @AutoImplement for Interface Defaults
What we’re doing: Using @AutoImplement to automatically generate default implementations for abstract methods, so you only need to override the methods you actually use.
Example 8: @AutoImplement
import groovy.transform.AutoImplement
// Without @AutoImplement, you'd need to implement ALL methods
@AutoImplement
class SimpleList implements List {
private items = []
int size() { items.size() }
boolean add(Object e) { items.add(e) }
Object get(int i) { items[i] }
}
def list = new SimpleList()
list.add("Rahul")
list.add("Groovy")
println "Size: ${list.size()}"
println "First: ${list.get(0)}"
println "Has iterator: ${list.iterator() != null}"
Output
Size: 2 First: Rahul Has iterator: false
What happened here: @AutoImplement generates default implementations for any unimplemented abstract methods in your class. For methods that return objects, it returns null; for numeric primitives, it returns 0; for booleans, false; and for collections, empty collections. This means you can implement a large interface like List and only override the methods you actually care about – the rest get safe, do-nothing defaults. That is exactly why list.iterator() returns null here (so iterator() != null prints false): we never implemented iterator(), so @AutoImplement supplied the default object return of null. This is especially useful in testing, prototyping, or when you need a partial implementation of a complex interface.
Example 9: @Newify for Alternative Constructor Syntax
What we’re doing: Using @Newify to call constructors without the new keyword, enabling Python-style or Ruby-style object creation.
Example 9: @Newify
import groovy.transform.ToString
import groovy.transform.TupleConstructor
@TupleConstructor
@ToString(includeNames = true)
class Vector2D {
double x
double y
}
@TupleConstructor
@ToString(includeNames = true)
class Color {
int r, g, b
}
// @Newify with specific classes - Python-style constructor
@Newify([Vector2D, Color])
def createScene() {
def origin = Vector2D(0, 0)
def target = Vector2D(10.5, 20.3)
def red = Color(255, 0, 0)
def blue = Color(0, 0, 255)
println "Origin: ${origin}"
println "Target: ${target}"
println "Red: ${red}"
println "Blue: ${blue}"
return [origin, target]
}
createScene()
// @Newify(auto = false) with Ruby-style .new()
@TupleConstructor
@ToString
class Point3D {
double x, y, z
}
@Newify(Point3D)
def createPoints() {
def p1 = Point3D.new(1, 2, 3)
def p2 = Point3D.new(4, 5, 6)
println "\nRuby-style:"
println "P1: ${p1}"
println "P2: ${p2}"
}
createPoints()
// @Newify at class level
@Newify([Vector2D, Color])
class Canvas {
List<Vector2D> points = []
Color background
Canvas() {
background = Color(30, 30, 30)
}
void addPoint(double x, double y) {
points << Vector2D(x, y)
}
}
def canvas = new Canvas()
canvas.addPoint(5, 10)
canvas.addPoint(15, 20)
println "\nCanvas background: ${canvas.background}"
println "Canvas points: ${canvas.points}"
Output
Origin: Vector2D(x:0.0, y:0.0) Target: Vector2D(x:10.5, y:20.3) Red: Color(r:255, g:0, b:0) Blue: Color(r:0, g:0, b:255) Ruby-style: P1: Point3D(1.0, 2.0, 3.0) P2: Point3D(4.0, 5.0, 6.0) Canvas background: Color(r:30, g:30, b:30) Canvas points: [Vector2D(x:5.0, y:10.0), Vector2D(x:15.0, y:20.0)]
What happened here: @Newify rewrites constructor calls at compile time. When you list classes in the annotation, you can call Vector2D(0, 0) instead of new Vector2D(0, 0). The Ruby-style Point3D.new(1, 2, 3) is also supported. You can apply @Newify to a method (scoped to that method) or to a class (scoped to all methods). This is purely syntactic sugar – the compiled bytecode is identical to using new. It’s most useful in DSLs and builder-style code where new keywords add visual noise.
Example 10: @Sortable for Automatic Comparable Implementation
What we’re doing: Generating Comparable implementation and per-property comparators automatically so objects can be sorted without writing comparison logic.
Example 10: @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: 'Prathamesh', lastName: 'Brown', gpa: 3.5),
new Student(firstName: 'Nirranjan', lastName: 'Smith', gpa: 3.9),
new Student(firstName: 'Viraj', lastName: 'Brown', gpa: 3.7),
new Student(firstName: 'Nirranjan', lastName: 'Brown', gpa: 3.8),
new Student(firstName: 'Prathamesh', lastName: 'Smith', gpa: 3.6),
]
// Default sort: by property declaration order (lastName, firstName, gpa)
println "Default sort (lastName, firstName, gpa):"
students.sort().each { println " ${it}" }
// Sort by a single property using generated comparator
println "\nSort by GPA only:"
students.sort(false, Student.comparatorByGpa()).each { println " ${it}" }
println "\nSort by firstName only:"
students.sort(false, Student.comparatorByFirstName()).each { println " ${it}" }
// @Sortable with includes - only sort by specific properties
@Sortable(includes = ['priority', 'title'])
@ToString(includeNames = true)
class Task {
int priority
String title
String assignee
Date created = new Date()
}
def tasks = [
new Task(priority: 2, title: 'Write tests', assignee: 'Nirranjan'),
new Task(priority: 1, title: 'Fix bug', assignee: 'Viraj'),
new Task(priority: 1, title: 'Deploy', assignee: 'Prathamesh'),
new Task(priority: 3, title: 'Add feature', assignee: 'Nirranjan'),
]
println "\nTasks sorted by priority, then title:"
tasks.sort().each { println " [P${it.priority}] ${it.title} (${it.assignee})" }
Output
Default sort (lastName, firstName, gpa): Student(lastName:Brown, firstName:Nirranjan, gpa:3.8) Student(lastName:Brown, firstName:Prathamesh, gpa:3.5) Student(lastName:Brown, firstName:Viraj, gpa:3.7) Student(lastName:Smith, firstName:Nirranjan, gpa:3.9) Student(lastName:Smith, firstName:Prathamesh, gpa:3.6) Sort by GPA only: Student(lastName:Brown, firstName:Prathamesh, gpa:3.5) Student(lastName:Smith, firstName:Prathamesh, gpa:3.6) Student(lastName:Brown, firstName:Viraj, gpa:3.7) Student(lastName:Brown, firstName:Nirranjan, gpa:3.8) Student(lastName:Smith, firstName:Nirranjan, gpa:3.9) Sort by firstName only: Student(lastName:Brown, firstName:Nirranjan, gpa:3.8) Student(lastName:Smith, firstName:Nirranjan, gpa:3.9) Student(lastName:Brown, firstName:Prathamesh, gpa:3.5) Student(lastName:Smith, firstName:Prathamesh, gpa:3.6) Student(lastName:Brown, firstName:Viraj, gpa:3.7) Tasks sorted by priority, then title: [P1] Deploy (Prathamesh) [P1] Fix bug (Viraj) [P2] Write tests (Nirranjan) [P3] Add feature (Nirranjan)
What happened here: @Sortable makes the class implement Comparable with a compareTo method that compares properties in declaration order. It also generates static comparatorByPropertyName() methods for each property, so you can sort by any single field. The includes option limits which properties are used for default comparison – in the Task example, assignee and created are ignored during sorting. This eliminates the tedious work of writing multi-field comparison logic by hand.
Example 11: @EqualsAndHashCode Advanced Options
What we’re doing: Fine-tuning equality and hash code generation with includes, excludes, callSuper, and cache options.
Example 11: @EqualsAndHashCode Options
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
// Equality based only on specific fields
@EqualsAndHashCode(includes = ['isbn'])
@ToString(includeNames = true)
class Book {
String isbn
String title
String author
int edition
}
def book1 = new Book(isbn: '978-0-13-468599-1', title: 'Groovy in Action', author: 'Koenig', edition: 2)
def book2 = new Book(isbn: '978-0-13-468599-1', title: 'Groovy in Action 2nd Ed', author: 'Koenig et al', edition: 2)
def book3 = new Book(isbn: '978-1-617-29238-1', title: 'Making Java Groovy', author: 'Kousen', edition: 1)
println "Same ISBN, different titles:"
println " book1 == book2: ${book1 == book2}" // true - same ISBN
println " book1 == book3: ${book1 == book3}" // false - different ISBN
// callSuper for inheritance hierarchies
@EqualsAndHashCode
@ToString(includeNames = true)
class Vehicle {
String make
String model
}
@EqualsAndHashCode(callSuper = true)
@ToString(includeNames = true, includeSuperProperties = true)
class Car extends Vehicle {
int doors
String color
}
def car1 = new Car(make: 'Toyota', model: 'Camry', doors: 4, color: 'Blue')
def car2 = new Car(make: 'Toyota', model: 'Camry', doors: 4, color: 'Blue')
def car3 = new Car(make: 'Honda', model: 'Civic', doors: 4, color: 'Blue')
println "\nWith callSuper:"
println " car1 == car2: ${car1 == car2}" // true
println " car1 == car3: ${car1 == car3}" // false - different make/model
// Cache for expensive hashCode
@EqualsAndHashCode(cache = true)
@ToString(includeNames = true)
class LargeRecord {
String id
String data
List<String> tags
}
def record = new LargeRecord(id: 'rec_1', data: 'x' * 10000, tags: (1..100).collect { "tag_$it" })
def hash1 = record.hashCode()
def hash2 = record.hashCode() // returned from cache
println "\nCached hashCode:"
println " hash1 == hash2: ${hash1 == hash2}"
println " Hash value: ${hash1}"
// Excludes to skip volatile/transient fields
@EqualsAndHashCode(excludes = ['lastAccessed', 'accessCount'])
@ToString(includeNames = true)
class CacheEntry {
String key
String value
long lastAccessed = System.currentTimeMillis()
int accessCount = 0
}
def e1 = new CacheEntry(key: 'user:42', value: 'Nirranjan')
e1.accessCount = 10
def e2 = new CacheEntry(key: 'user:42', value: 'Nirranjan')
e2.accessCount = 5
println "\nExcluding volatile fields:"
println " e1 == e2: ${e1 == e2}" // true - accessCount excluded
Output
Same ISBN, different titles: book1 == book2: true book1 == book3: false With callSuper: car1 == car2: true car1 == car3: false Cached hashCode: hash1 == hash2: true Hash value: -975866785 Excluding volatile fields: e1 == e2: true
What happened here: The includes option on Book means equality is determined solely by ISBN – two books with the same ISBN are equal even if their titles differ. The callSuper option on Car ensures the parent class’s properties (make, model) are included in equality checks. The cache option stores the computed hash code after the first call, which helps when objects are used as map keys or in sets. The excludes option on CacheEntry skips metadata fields that change between accesses but shouldn’t affect equality.
Example 12: Combining Multiple AST Transforms
What we’re doing: Stacking multiple AST transforms on a single class to build a fully-featured domain object with minimal code.
Example 12: Combining Multiple Transforms
import groovy.transform.*
@TupleConstructor(includes = ['name', 'email', 'role'])
@MapConstructor
@ToString(includeNames = true, excludes = ['createdAt'])
@EqualsAndHashCode(includes = ['email'])
@Sortable(includes = ['role', 'name'])
@AutoClone
class TeamMember {
String name
String email
String role
Date createdAt = new Date()
boolean active = true
}
// Create using tuple constructor
def nirranjan = new TeamMember('Nirranjan', 'nirranjan@team.com', 'Developer')
def viraj = new TeamMember('Viraj', 'viraj@team.com', 'Designer')
def prathamesh = new TeamMember('Prathamesh', 'prathamesh@team.com', 'Developer')
def prathameshMgr = new TeamMember('Prathamesh', 'prathamesh.mgr@team.com', 'Manager')
// @ToString in action
println "Team members:"
[nirranjan, viraj, prathamesh, prathameshMgr].each { println " ${it}" }
// @EqualsAndHashCode by email drives equals(), but @Sortable makes the
// class Comparable, so the == operator here uses compareTo (role, name)
def nirranjanDuplicate = new TeamMember('Nirranjan Smith', 'nirranjan@team.com', 'Senior Developer')
println "\nnirranjan == nirranjanDuplicate (== uses compareTo): ${nirranjan == nirranjanDuplicate}"
println "nirranjan.equals(nirranjanDuplicate) (same email): ${nirranjan.equals(nirranjanDuplicate)}"
println "nirranjan == viraj: ${nirranjan == viraj}"
// @Sortable by role, then name
def team = [prathameshMgr, prathamesh, nirranjan, viraj]
println "\nSorted team:"
team.sort().each { println " ${it.role.padRight(12)} ${it.name}" }
// @AutoClone
def cloned = nirranjan.clone()
cloned.role = 'Senior Developer'
cloned.active = false
println "\nOriginal: ${nirranjan}"
println "Cloned: ${cloned}"
// Using in a Set (relies on @EqualsAndHashCode via equals/hashCode)
def teamSet = [nirranjan, viraj, prathamesh, prathameshMgr, nirranjanDuplicate] as Set
println "\nSet size (duplicate email removed): ${teamSet.size()}"
// @MapConstructor
def tester = new TeamMember(name: 'Viraj', email: 'viraj@team.com', role: 'Tester')
println "Map constructor: ${tester}"
Output
Team members: TeamMember(name:Nirranjan, email:nirranjan@team.com, role:Developer, active:true) TeamMember(name:Viraj, email:viraj@team.com, role:Designer, active:true) TeamMember(name:Prathamesh, email:prathamesh@team.com, role:Developer, active:true) TeamMember(name:Prathamesh, email:prathamesh.mgr@team.com, role:Manager, active:true) nirranjan == nirranjanDuplicate (== uses compareTo): false nirranjan.equals(nirranjanDuplicate) (same email): true nirranjan == viraj: false Sorted team: Designer Viraj Developer Nirranjan Developer Prathamesh Manager Prathamesh Original: TeamMember(name:Nirranjan, email:nirranjan@team.com, role:Developer, active:true) Cloned: TeamMember(name:Nirranjan, email:nirranjan@team.com, role:Senior Developer, active:false) Set size (duplicate email removed): 4 Map constructor: TeamMember(name:Viraj, email:viraj@team.com, role:Tester, active:true)
What happened here: We stacked six AST transforms on a single class. @TupleConstructor gives us positional construction; @MapConstructor gives us named construction; @ToString gives us readable output (excluding the noisy createdAt field); @EqualsAndHashCode by email means two members with the same email are equal, so the duplicate is caught when we build the Set (size 4, not 5); @Sortable by role and name gives us natural ordering; and @AutoClone lets us copy members easily.
The class body has just five property declarations – everything else is generated. One subtlety worth knowing: because @Sortable makes TeamMember implement Comparable, Groovy’s == operator uses compareTo (which compares role and name), not equals. That is why nirranjan == nirranjanDuplicate prints false even though their emails match, while nirranjan.equals(nirranjanDuplicate) and the Set dedup (both driven by @EqualsAndHashCode) still treat them as the same member. This is the sweet spot of Groovy AST transforms: maximum functionality from minimum code, as long as you know which operator hits which generated method.
Common Pitfalls
These are the mistakes that trip up developers most often when working with groovy advanced AST transforms.
Pitfall 1: @TupleConstructor Conflicts with Manual Constructors
If you define a manual constructor, @TupleConstructor may not generate the expected constructors because of signature conflicts.
Bad: Manual constructor conflicts
import groovy.transform.TupleConstructor
// BAD: Manual constructor with same signature blocks generated one
@TupleConstructor
class Widget {
String name
int size
// This constructor has the same signature as the generated one!
Widget(String name, int size) {
this.name = name.toUpperCase()
this.size = size * 2
}
}
// The @TupleConstructor is silently ignored
Good: Use force or avoid conflicts
import groovy.transform.TupleConstructor
// GOOD: Use force = true to override manual constructors
@TupleConstructor(force = true)
class Widget {
String name
int size
}
// Or use pre/post closures for customization
@TupleConstructor(post = { name = name?.toUpperCase() })
class WidgetV2 {
String name
int size
}
def w = new WidgetV2('hello', 5)
println w.name // HELLO
Pitfall 2: @Memoized on Methods with Side Effects
@Memoized caches results. If your method has side effects (logging, incrementing counters, writing to a database), those side effects will only happen on the first call.
Bad: Memoizing a method with side effects
import groovy.transform.Memoized
class OrderService {
// BAD: This method sends an email - memoization means it only sends once!
@Memoized
String processOrder(String orderId) {
sendConfirmationEmail(orderId) // Side effect!
return "Processed: ${orderId}"
}
}
Good: Only memoize pure functions
import groovy.transform.Memoized
class PricingService {
// GOOD: Pure calculation - same inputs always give same output
@Memoized
BigDecimal calculateDiscount(String tier, BigDecimal amount) {
def rate = switch(tier) {
case 'gold' -> 0.15
case 'silver' -> 0.10
case 'bronze' -> 0.05
default -> 0.0
}
return (amount * rate).setScale(2, BigDecimal.ROUND_HALF_UP)
}
}
Pitfall 3: @EqualsAndHashCode Without callSuper in Subclasses
When using @EqualsAndHashCode in a class hierarchy, forgetting callSuper = true means parent properties are ignored in equality checks.
Bad: Missing callSuper
import groovy.transform.EqualsAndHashCode
@EqualsAndHashCode
class Animal { String species }
// BAD: Only compares 'name', ignoring 'species' from parent
@EqualsAndHashCode
class Pet extends Animal { String name }
def cat = new Pet(species: 'Cat', name: 'Whiskers')
def dog = new Pet(species: 'Dog', name: 'Whiskers')
println cat == dog // true! Both named Whiskers, species ignored
Good: Use callSuper = true
import groovy.transform.EqualsAndHashCode
@EqualsAndHashCode
class Animal { String species }
// GOOD: Includes parent's species in equality
@EqualsAndHashCode(callSuper = true)
class Pet extends Animal { String name }
def cat = new Pet(species: 'Cat', name: 'Whiskers')
def dog = new Pet(species: 'Dog', name: 'Whiskers')
println cat == dog // false - different species
Best Practices
After working through all 12 examples, here are the guidelines that will keep your AST-transform-heavy code clean and maintainable.
- DO use
@TupleConstructoror@MapConstructorinstead of writing constructors by hand. The generated code is tested and consistent. - DO use
includesorexcludeson@EqualsAndHashCodeto control which fields define identity. Not every field should participate in equality. - DO use
@Delegateto favor composition over inheritance. It generates cleaner APIs than manual wrapper methods. - DO use
@Memoizedonly on pure functions – methods where the same inputs always produce the same output with no side effects. - DO use
@Sortable(includes = [...])to make the sort order explicit. Default property-declaration-order sorting is fragile if you later reorder properties. - DON’T stack more than 5-6 transforms on a single class. If you find yourself adding more, consider whether your class is doing too much.
- DON’T use
@Memoizedon methods with side effects (database writes, emails, logging you depend on). The side effect only happens on the first call. - DON’T forget
callSuper = trueon@EqualsAndHashCodein subclasses. Without it, parent properties are invisible to equality checks. - DON’T use
@AutoClonewith the defaultCLONEstyle for classes with deep object graphs. UseSERIALIZATIONstyle for true deep copies.
Conclusion
Groovy advanced AST transforms go far beyond @ToString and @Immutable. We covered constructor generation with @TupleConstructor and @MapConstructor, delegation with @Delegate, automatic caching with @Memoized, object copying with @AutoClone, interface defaults with @AutoImplement, constructor syntax with @Newify, natural ordering with @Sortable, and fine-tuned equality with @EqualsAndHashCode options.
The pattern is always the same: declare your intent with an annotation, and let the compiler generate the implementation. This isn’t just about saving keystrokes – generated code is consistent, tested, and immune to the copy-paste errors that plague hand-written boilerplate. Stack transforms thoughtfully, use includes/excludes to stay precise, and remember that every transform adds a bit of implicit behavior to your class.
For the foundations of AST transforms, see our Groovy AST Transformations guide. To learn about building your own transforms from scratch, check Groovy Custom Annotations. And to see how these transforms evolved in the latest Groovy release, read our next post on Groovy 4/5 Modern Features.
Up next: Groovy 4/5 Modern Features
Frequently Asked Questions
What is the difference between @TupleConstructor and @MapConstructor in Groovy?
@TupleConstructor generates a positional constructor where arguments are passed in property declaration order – like new Person('Nirranjan', 30). @MapConstructor generates a constructor that accepts a Map – like new Person(name: 'Nirranjan', age: 30). You can use both on the same class to support both styles. The key difference is that tuple constructors are order-dependent while map constructors are name-dependent.
How does @Delegate work in Groovy?
@Delegate generates forwarding methods on the owning class for every public method of the delegate field. If class A has a @Delegate field of type B, then every public method of B becomes a method of A that simply calls the corresponding method on B. This implements the delegation pattern automatically. Use includes or excludes to control which methods are forwarded, and interfaces = true to make the owning class implement the delegate’s interfaces.
When should I use @Memoized in Groovy?
Use @Memoized on pure functions – methods where the same inputs always produce the same output and there are no side effects. Good candidates include mathematical computations, string formatting, configuration lookups, and data transformations. Never use it on methods that write to databases, send emails, log important events, or modify external state, because those side effects will only happen on the first call with each unique set of arguments.
What are the different @AutoClone styles in Groovy?
@AutoClone supports three styles: CLONE (default) uses super.clone() and clones Cloneable fields – fast but shallow for non-Cloneable nested objects. COPY_CONSTRUCTOR generates a copy constructor and uses it in clone() – good for classes with mutable collections. SERIALIZATION serializes and deserializes the object – slowest but guarantees a true deep copy of the entire object graph. Choose based on your depth and performance requirements.
Can I combine multiple AST transforms on one Groovy class?
Yes, and it’s common practice. You can stack @TupleConstructor, @ToString, @EqualsAndHashCode, @Sortable, @AutoClone, and others on a single class. Each transform generates its own methods independently. Use includes and excludes on each transform to control which properties participate. As a guideline, keep it under 5-6 transforms per class – if you need more, your class may be doing too much. Example 12 in this guide stacks six groovy advanced AST transforms on one domain class.
Interview Questions on Advanced AST Transforms
Scenario questions, not trivia: this is the form this topic takes in a real interview.
Q: After someone adds @Sortable to a class that already had @EqualsAndHashCode, a Spock assertion a == b flips from passing to failing with no data change. Walk me through why.
@Sortable makes the class implement Comparable, and Groovy’s == operator treats Comparable types specially: it calls compareTo() == 0 instead of equals(). If @Sortable compares different properties than @EqualsAndHashCode, say role and name versus email, the two operators now disagree about the same pair of objects. The fix is aligning the includes lists of both transforms, or asserting with a.equals(b) when identity semantics are what the test means. Knowing that == is not always equals() in Groovy is the whole point of the question.
Q: A @Memoized method on a long-lived service slowly eats the heap of a Jenkins agent over several weeks. Why, and what guardrails would you add?
By default @Memoized caches without bound: every distinct argument tuple adds an entry that lives as long as the object does, and for a singleton service on a long-running JVM that means forever. If the arguments include something high-cardinality, like request IDs or timestamps, the cache is effectively a leak by design. Guardrails: set maxCacheSize so eviction kicks in, only memoize methods whose argument space is small and stable, and confirm the method is genuinely pure first. For anything needing time-based expiry, use a real cache library instead of the annotation.
Q: A test double built with @AutoImplement causes a NullPointerException three layers away from the stub. What is the failure mode, and how do you make it fail closer to the cause?
@AutoImplement fills in unimplemented methods with quiet defaults: null for objects, zero for numbers, false for booleans. That is convenient until production code calls a stubbed method you forgot about, receives null, and passes it along until something dereferences it far from the stub. The annotation has the cure built in: @AutoImplement(exception = UnsupportedOperationException) makes every generated method throw immediately, so the first unexpected call fails loudly at the right line. In Spock-based suites, an honest answer also mentions that a proper Mock() or Stub() usually beats a hand-rolled partial implementation.
Q: An Employee extends Person class uses @EqualsAndHashCode, and a Set treats two employees with the same employee ID but different names as duplicates. Where is the bug?
The subclass annotation only looks at the subclass’s own properties unless you say otherwise, so name, declared on Person, is invisible to the generated equals() and hashCode(). Two employees with equal subclass fields collapse into one Set entry regardless of parent state. The fix is @EqualsAndHashCode(callSuper = true), and the parent needs its own consistent equality for that to mean anything. The sharper interview follow-up is whether name should participate in identity at all; often the right answer is includes = ['employeeId'] and making the contract explicit.
Q: A teammate reorders the properties of a domain class for readability and CI lights up: constructor calls break and sorted reports change order. Which transforms made declaration order part of the public API?
@TupleConstructor and @Sortable, plus @ToString cosmetically. The tuple constructor’s parameter order is the property declaration order, so swapping two same-typed fields silently swaps every positional call site’s meaning, and @Sortable without includes compares properties in declaration order, so the report’s sort key changed too. The defensive habits: pin order explicitly with includes on @Sortable, prefer @MapConstructor or named arguments at call sites so construction is name-based, and treat property order in an annotated Groovy class as API, not formatting.
Related Posts
Previous in Series: Groovy Number and Math Operations
Next in Series: Groovy 4/5 Modern Features
Related Topics You Might Like:
- Groovy AST Transformations – Compile-Time Metaprogramming
- Groovy Custom Annotations
- Groovy Immutable Objects
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment