Groovy Advanced AST Transforms – Part 2 with 10+ Examples

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 @TupleConstructor and @MapConstructor generate constructors automatically
  • How @Delegate implements delegation without writing wrapper methods
  • How @Memoized caches method results transparently
  • How @AutoClone and @AutoImplement handle object copying and interface implementation
  • How @Newify gives you alternative constructor syntax
  • How @Sortable generates Comparable implementations
  • How @EqualsAndHashCode options let you fine-tune equality
  • How to combine multiple transforms for maximum effect

Quick Reference Table

TransformPurposeKey Options
@TupleConstructorPositional constructor from propertiesincludes, excludes, includeSuperProperties, defaults
@MapConstructorMap-based constructor from propertiesincludes, excludes, noArg
@DelegateDelegate method calls to a fieldinterfaces, includes, excludes, deprecated
@MemoizedCache method return valuesmaxCacheSize, protectedCacheSize
@AutoCloneGenerate clone() methodstyle (CLONE, COPY_CONSTRUCTOR, SERIALIZATION)
@AutoImplementGenerate default implementations for abstract methodsexception, code
@NewifyAlternative constructor syntaxauto, class list
@SortableGenerate Comparable and comparatorsincludes, excludes
@EqualsAndHashCodeGenerate equals/hashCodeincludes, excludes, callSuper, cache

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 @TupleConstructor or @MapConstructor instead of writing constructors by hand. The generated code is tested and consistent.
  • DO use includes or excludes on @EqualsAndHashCode to control which fields define identity. Not every field should participate in equality.
  • DO use @Delegate to favor composition over inheritance. It generates cleaner APIs than manual wrapper methods.
  • DO use @Memoized only 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 @Memoized on methods with side effects (database writes, emails, logging you depend on). The side effect only happens on the first call.
  • DON’T forget callSuper = true on @EqualsAndHashCode in subclasses. Without it, parent properties are invisible to equality checks.
  • DON’T use @AutoClone with the default CLONE style for classes with deep object graphs. Use SERIALIZATION style 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.

Previous in Series: Groovy Number and Math Operations

Next in Series: Groovy 4/5 Modern Features

Related Topics You Might Like:

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

RahulAuthor posts

Avatar for Rahul

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

No comment

Leave a Reply

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