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.”
Dierk König, Groovy in Action
Last Updated: March 2026 | Tested on: Groovy 5.x, 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.
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
12 Practical 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:null, 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.
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.
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: ReadOnlyRepository.close() 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.
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! = 30414093201713378043612608166979581188299763898377856820553615673507270386838265 50! again = 30414093201713378043612608166979581188299763898377856820553615673507270386838265 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.
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: true
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. 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:0) 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:Viraj, gpa:3.7) Student(lastName:Brown, firstName:Prathamesh, gpa:3.5) 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:Smith, firstName:Nirranjan, gpa:3.9) Student(lastName:Brown, firstName:Nirranjan, gpa:3.8) Student(lastName:Brown, firstName:Viraj, gpa:3.7) Student(lastName:Brown, firstName:Prathamesh, gpa:3.5) Student(lastName:Smith, firstName:Prathamesh, gpa:3.6) 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: 1284729374 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 prathamesh = new TeamMember('Prathamesh', 'prathamesh@team.com', 'Manager')
// @ToString in action
println "Team members:"
[nirranjan, viraj, prathamesh, prathamesh].each { println " ${it}" }
// @EqualsAndHashCode by email
def aliceDuplicate = new TeamMember('Nirranjan Smith', 'nirranjan@team.com', 'Senior Developer')
println "\nalice == aliceDuplicate (same email): ${nirranjan == aliceDuplicate}"
println "nirranjan == viraj: ${nirranjan == viraj}"
// @Sortable by role, then name
def team = [prathamesh, 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)
def teamSet = [nirranjan, viraj, prathamesh, prathamesh, aliceDuplicate] as Set
println "\nSet size (duplicate email removed): ${teamSet.size()}"
// @MapConstructor
def viraj = new TeamMember(name: 'Viraj', email: 'viraj@team.com', role: 'Tester')
println "Map constructor: ${viraj}"
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@team.com, role:Manager, active:true) nirranjan == aliceDuplicate (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 duplicates are caught in Sets; @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. This is the sweet spot of Groovy AST transforms: maximum functionality from minimum code.
Common Pitfalls
These are the mistakes that trip up developers most often when working with Groovy AST transformations.
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’s AST transformations 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.
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