AST Transformations: Code Generation & Class Design

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). An AST transformation is a compiler plug-in triggered by an annotation: while the class is being compiled, the transformation rewrites its syntax tree and adds fields, methods, constructors or interfaces, so the boilerplate that Java programmers write or generate with an IDE is simply never written. The transformations here are all local ones, bundled in the Groovy core, and they cover two jobs: generating the members every data class needs, and shaping how a class is built, shared and called. The declarative-safety, logging, concurrency and compiler-directive transformations are on AST Transformations: Logging, Safety, Concurrency & Directives, and how to write your own is on Writing AST Transformations & Macros. Java records (Groovy’s own record) are on Records & Sealed Types, and the properties these transformations read are explained on Classes, Fields & Properties.

Most of the annotations share option names: includes and excludes choose properties, includeFields adds fields, includeSuper and callSuper involve the parent class, and includeNames or defaults change the shape of the generated code. Because the output is ordinary bytecode, it can be seen with reflection, and the examples below do that rather than trusting the documentation. Unless an example imports something else, groovy.transform.* is imported.

Generating the data-class members

@ToString

@ToString adds a toString() that lists the property values. The name of the class is included, the package is included unless includePackage = false, names can be switched on with includeNames, and includeSuper and includeFields add the parent’s text and the non-property fields.

import groovy.transform.*

@ToString(includeNames = true, excludes = ['notes'], includePackage = false)
class Bed {
    String crop
    int plants
    String notes
}
println new Bed(crop: 'basil', plants: 12, notes: 'north')

@ToString(includeSuper = true, includeFields = true)
class Tray extends Bed { private int slots = 6 }
println new Tray(crop: 'mint', plants: 3)
Bed(crop:basil, plants:12)
Tray(6, Bed(crop:mint, plants:3))

@EqualsAndHashCode

@EqualsAndHashCode generates equals, hashCode and a canEqual helper, all driven by the properties, so two instances with the same values are equal and can be used as map keys. Without it a Groovy class keeps Java’s identity equality (the == operator calls equals; see Operators: Arithmetic, Relational & Logical).

import groovy.transform.*

@EqualsAndHashCode
class Sensor { String id; double reading }

def a = new Sensor(id: 's1', reading: 1.5)
def b = new Sensor(id: 's1', reading: 1.5)
assert a == b && !a.is(b)
assert a.hashCode() == b.hashCode()
assert [a].contains(b)

@EqualsAndHashCode(includes = 'id')
class Zone { String id; String label }
assert new Zone(id: 'z1', label: 'a') == new Zone(id: 'z1', label: 'b')
println Sensor.declaredMethods*.name.findAll { it in ['equals', 'hashCode', 'canEqual'] }.sort()
[canEqual, equals, hashCode]

@TupleConstructor

@TupleConstructor generates a positional constructor with one parameter per property, in declaration order. Properties with a default value make the trailing parameters optional, which the compiler expresses as one overload per optional parameter. includes and excludes select properties, defaults = false removes the optional overloads, and callSuper with includeSuperProperties extends the signature with the parent’s properties first.

import groovy.transform.*

@TupleConstructor @ToString
class Plot { String name; int rows = 4; int cols = 2 }
println new Plot('p1')
println new Plot('p1', 8)
println new Plot('p1', 8, 3)
println Plot.constructors.collect { it.parameterCount }.sort()

@TupleConstructor(includes = ['cols'], defaults = false)
class OnlyCols { int cols; String name }
println new OnlyCols(5).cols

@TupleConstructor(callSuper = true, includeSuperProperties = true)
class Sub extends Plot { String extra = 'x' }
def s = new Sub('p2', 1, 1, 'y')
println "${s.name} ${s.rows} ${s.cols} ${s.extra}"
Plot(p1, 4, 2)
Plot(p1, 8, 2)
Plot(p1, 8, 3)
[0, 1, 2, 3]
5
p2 1 1 y

@MapConstructor

@MapConstructor generates a constructor taking a Map. Groovy’s own new Probe(id: 'p', depth: 9) call already works on classes with a no-argument constructor by setting properties after construction; the annotation matters when the properties are final, or the class must be built through a single constructor, or when Java code needs a real map-taking constructor.

import groovy.transform.*

@MapConstructor
class Probe { final String id; final int depth }
def p = new Probe([id: 'p', depth: 9])
println "${p.id} ${p.depth}"
println Probe.constructors.collect { it.parameterTypes*.simpleName }
p 9
[[Map]]

@Canonical

@Canonical bundles the three previous transformations: it is an annotation collector that applies @ToString, @EqualsAndHashCode and @TupleConstructor with their defaults. It adds no code of its own. Each of the three skips generation when the class already declares the method, so a hand-written toString() wins. The options of the three are accepted on @Canonical as well.

What @Canonical generates: a class with two properties becomes a class with a toString, an equals and hashCode, and tuple constructors
import groovy.transform.*

@Canonical
class Reading { String sensor; double value = 0.0 }

def r = new Reading('t1', 21.5)
println r
assert r == new Reading(sensor: 't1', value: 21.5)
assert new Reading('t1').value == 0.0
println Reading.declaredMethods.findAll { !it.synthetic }*.name.sort().unique()
println Reading.declaredConstructors.collect { it.parameterTypes*.simpleName }.sort { it.size() }

@Canonical
class Custom {
    String x
    String toString() { 'mine' }
}
println new Custom('q')
Reading(t1, 21.5)
[canEqual, equals, getMetaClass, getSensor, getValue, hashCode, setMetaClass, setSensor, setValue, toString]
[[], [String], [String, double]]
mine

The generated canEqual and the pair of MetaClass accessors in the list belong to @EqualsAndHashCode and to every Groovy class respectively. The excludes option of @Canonical is passed to all three annotations, so it also removes the property from the constructor, which is easy to miss:

import groovy.transform.*

@Canonical(excludes = 'value')
class Tag { String sensor; double value }

println new Tag('a')
println new Tag(sensor: 'a', value: 5).value
try {
    new Tag('a', 1)
} catch (e) {
    println e.message.readLines()[0]
}
Tag(a)
5.0
Could not find matching constructor for: Tag(String, Integer)

@InheritConstructors

Groovy constructors are not inherited, as in Java. @InheritConstructors copies every constructor of the superclass into the subclass, each delegating to super. It is most useful when extending exception classes and other classes with many constructors.

import groovy.transform.*

class Base {
    String name
    Base(String n) { name = n }
    Base(String n, int k) { name = n * k }
}

@InheritConstructors
class Child extends Base { }

println new Child('ab').name
println new Child('ab', 2).name

@InheritConstructors
class ChannelException extends IllegalStateException { }
println new ChannelException('closed').message
ab
abab
closed

@Category

@Category(Target) turns a class into a category: its instance methods are rewritten as static methods taking the target as the first parameter, so that inside the class this is the target object. A category is activated with the use block. The categories mechanism itself, its scoping and its alternatives are on Metaclasses, ExpandoMetaClass & Categories.

import groovy.transform.*

@Category(String)
class Shout {
    String shout() { toUpperCase() + '!' }
}

use(Shout) { println 'hey'.shout() }
println Shout.declaredMethods.find { it.name == 'shout' }.parameterTypes*.simpleName
HEY!
[String]

@IndexedProperty

A property holding a list or an array gets its normal getter and setter. @IndexedProperty adds the JavaBeans indexed accessors getName(int) and setName(int, value) that bean-aware frameworks look for.

import groovy.transform.*

class Matrixish {
    @IndexedProperty List<String> names = ['a', 'b']
    @IndexedProperty String[] codes = new String[2]
}
def m = new Matrixish()
m.setCodes(0, 'A')
m.setCodes(1, 'B')
println m.getCodes(1)
m.setNames(0, 'y')
println m.getNames(0)
println Matrixish.declaredMethods*.name.findAll { it =~ /^(get|set)(Names|Codes)$/ }.sort()
B
y
[getCodes, getCodes, getNames, getNames, setCodes, setCodes, setNames, setNames]

@Lazy

@Lazy on a field replaces the initialiser with a lazily evaluated, thread-safe one: the right-hand side runs on the first read, and the field is stored under a $-prefixed name. With soft = true the value is held through a soft reference so the JVM may drop and recompute it under memory pressure.

import groovy.transform.*

class Beds {
    static int loads = 0
    @Lazy List<String> crops = { loads++; ['basil', 'mint'] }()
    @Lazy(soft = true) String big = 'x' * 5
}
def b = new Beds()
println Beds.loads
println b.crops
println b.crops
println Beds.loads
println Beds.declaredFields*.name.findAll { it.contains('crops') || it.contains('big') }.sort()
0
[basil, mint]
[basil, mint]
1
[$big, $crops]

@Newify

@Newify lets code construct objects without the new keyword, in two styles: @Newify([Hose]) makes Hose(...) a constructor call for the listed classes (a Python-like style), and a bare @Newify enables Hose.new(...) (a Ruby-like style). It applies to the annotated class, method or script scope and can make tree-building code easier to read.

import groovy.transform.*

class Hose {
    String id
    Hose(String id) { this.id = id }
    String toString() { "Hose($id)" }
}

@Newify([Hose])
def make() { [Hose('a'), Hose('b')] }
println make()

@Newify
def make2() { [Hose.new('c')] }
println make2()
[Hose(a), Hose(b)]
[Hose(c)]

@Sortable

@Sortable makes a class Comparable by comparing its properties in declaration order, and adds one static comparatorByXxx() method per property. includes and excludes choose and order the participating properties.

import groovy.transform.*

@Sortable(excludes = 'notes')
class Seed { String name; int year; String notes }

def seeds = [
    new Seed(name: 'mint', year: 2024),
    new Seed(name: 'basil', year: 2025),
    new Seed(name: 'basil', year: 2023)
]
println seeds.sort(false)*.with { "$name $year" }
println seeds.sort(false, Seed.comparatorByYear())*.year
println Seed.declaredMethods*.name.findAll { it.startsWith('comparator') }.sort()
println Seed.interfaces*.simpleName

@Sortable(includes = ['year', 'name'])
class Seed2 { String name; int year }
println([new Seed2(name: 'b', year: 1), new Seed2(name: 'a', year: 1), new Seed2(name: 'z', year: 0)].sort()*.name)
[basil 2023, basil 2025, mint 2024]
[2023, 2024, 2025]
[comparatorByName, comparatorByYear]
[Comparable, GroovyObject]
[z, a, b]

@Builder and its strategies

@Builder generates a fluent builder, and its builderStrategy option picks the shape. The annotation lives in groovy.transform.builder together with the strategies, which are classes passed to the option.

Strategy What it produces

DefaultStrategy (the default)

A static builder() method and a nested builder class with one method per property and a build() method.

SimpleStrategy

Chained setters on the class itself, optionally with a prefix; no separate builder object and no build().

ExternalStrategy

A builder class that you declare, annotated with forClass, so the target class need not be modifiable.

InitializerStrategy

A type-safe initialiser used with @CompileStatic: the compiler checks that every property was set.

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

@Builder
class Pot { String name; int litres }
def p = Pot.builder().name('clay').litres(5).build()
println "${p.name} ${p.litres}"

@Builder(builderStrategy = ExternalStrategy, forClass = Pot)
class PotBuilder { }
def p2 = new PotBuilder().name('tin').litres(2).build()
println "${p2.name} ${p2.litres}"

@Builder(builderStrategy = SimpleStrategy, prefix = 'with')
class Label { String text; int size }
def l = new Label().withText('hi').withSize(3)
println "${l.text} ${l.size}"

@Builder(builderStrategy = DefaultStrategy, builderClassName = 'Maker', builderMethodName = 'maker',
         buildMethodName = 'create', prefix = 'set')
class Gadget { String id }
println Gadget.maker().setId('g').create().id

@Builder(builderStrategy = InitializerStrategy)
class Point { int x; int y }

@CompileStatic
def mk() { new Point(Point.createInitializer().x(1).y(2)) }
def pt = mk()
println "${pt.x},${pt.y}"
clay 5
tin 2
hi 3
g
1,2

@Builder can also annotate a method or a constructor, in which case the builder collects that member’s parameters instead of the class’s properties. In that position includes and excludes are not allowed (the compiler reports "includes/excludes only allowed on classes").

import groovy.transform.builder.*

class Mixed {
    String a
    int b
    @Builder
    static Mixed of(String a, int b) { new Mixed(a: a, b: b) }
}
println Mixed.builder().a('q').b(4).build().b
4

@AutoImplement

@AutoImplement supplies a body for every abstract method the class inherits and does not implement. By default a method returns the default value of its return type (null, 0, false); exception and message make it throw instead, and code provides a closure used as the body. It is convenient for test doubles.

import groovy.transform.*

interface Waterer { void water(int ml); String name(); int level() }

@AutoImplement
class Dummy implements Waterer { }
def d = new Dummy()
println d.name()
println d.level()
d.water(3)

@AutoImplement(exception = UnsupportedOperationException, message = 'not yet')
class Strict implements Waterer { }
try { new Strict().water(1) } catch (e) { println "${e.class.simpleName}: ${e.message}" }

interface Gauge { int level(); int max() }
@AutoImplement(code = { 42 })
class Fixed implements Gauge { }
println new Fixed().level()
null
0
UnsupportedOperationException: not yet
42

@NullCheck

@NullCheck inserts a guard at the top of a method or constructor that throws IllegalArgumentException when a reference parameter is null. On a class it covers every method and constructor of that class. A constructor produced by another transformation is checked only with includeGenerated = true, because the order in which the transformations run would otherwise leave it unguarded.

import groovy.transform.*

@NullCheck
class Valve {
    String id
    Valve(String id) { this.id = id }
    void set(String v) { id = v }
}
try { new Valve(null) } catch (e) { println "${e.class.simpleName}: ${e.message}" }
try { new Valve('a').set(null) } catch (e) { println "${e.class.simpleName}: ${e.message}" }

@NullCheck
@TupleConstructor
class V2 { String a }
try { new V2(null); println 'no check' } catch (e) { println e.message }

@NullCheck(includeGenerated = true)
@TupleConstructor
class V3 { String a }
try { new V3(null); println 'no check' } catch (e) { println e.message }
IllegalArgumentException: id cannot be null
IllegalArgumentException: v cannot be null
no check
a cannot be null

@POJO and @RecordType

@POJO (in the groovy.transform.stc package) asks the compiler to leave out the GroovyObject plumbing from a class that is compiled statically, so the class is a plain Java object with no getMetaClass/setMetaClass. It takes effect together with @CompileStatic; on a dynamic class the interface is still added.

@RecordType turns a class into a record: with the default RecordTypeMode.AUTO it becomes a real java.lang.Record on JDK 16+ and, with EMULATE, a class that imitates the record API on older runtimes. It adds a tuple constructor, toString, and component accessors such as x(). Real record declarations are on Records & Sealed Types; @RecordType is for the case where the data class is already written with properties.

import groovy.transform.*
import groovy.transform.stc.POJO

@POJO @CompileStatic @ToString
class Plain { String name; int n }
println new Plain(name: 'x', n: 1)
println Plain.interfaces*.simpleName

@ToString
class Normal { String name }
println Normal.interfaces*.simpleName

@RecordType
class Coord { int x; int y }
def c = new Coord(1, 2)
println c
println Coord.isRecord()
println Coord.superclass.simpleName
println c.x()

@RecordType
@RecordOptions(mode = RecordTypeMode.EMULATE)
class Emu { int x }
println Emu.isRecord()
println new Emu(3)
Plain(x, 1)
[]
[GroovyObject]
Coord[x=1, y=2]
true
Record
1
false
Emu[x=3]

@NamedVariant

@NamedVariant generates an overload of a method or constructor that takes its parameters as named arguments, which is the Groovy call style, while keeping the positional original. Without @NamedParam every parameter becomes a named argument; once some parameters are annotated, only the annotated ones are named (and can be required) and the rest stay positional. A missing required name is reported at the call.

import groovy.transform.*

@NamedVariant
String describe(String crop, int plants = 1) { "$crop x $plants" }
println describe(crop: 'basil', plants: 4)
println describe('mint', 2)
println describe(crop: 'sage')

@NamedVariant
String des2(@NamedParam(required = true) String crop, @NamedParam String note, int other) {
    "$crop $note $other"
}
println des2(crop: 'a', note: 'b', 7)
try { des2(note: 'b', 7) } catch (AssertionError e) { println e.message.readLines()[0] }
basil x 4
mint x 2
sage x 1
a b 7
Missing required named argument 'crop'. Keys found: [note]. Expression: namedArgs.containsKey(crop)

@OperatorRename

Groovy maps operators to methods (` calls `plus`), so a class from a library whose method has a different name cannot be used with the operator. `@OperatorRename` renames the mapping inside the annotated code, here pointing ` at add. It adapts third-party types to the operators without changing them. The operator-to-method table is on Operator Overloading.

import groovy.transform.*

class Vec {
    int v
    Vec(int v) { this.v = v }
    Vec add(Vec o) { new Vec(v + o.v) }
    String toString() { "Vec($v)" }
}

@OperatorRename(plus = 'add')
def calc() { new Vec(1) + new Vec(2) }
println calc()
Vec(3)

Class design

@Delegate

@Delegate on a field or property exposes the delegate’s public methods on the owning class, each forwarding to the held object, and makes the class implement the delegate’s interfaces. This is composition without writing the forwarding methods. interfaces = false suppresses the interface implementation, excludes and includes filter methods, and deprecated = true also forwards @Deprecated ones.

import groovy.transform.*

class Engine { int starts; void start() { starts++ }; int rpm() { 900 } }
class Tractor {
    @Delegate Engine engine = new Engine()
    String name = 'T1'
}
def t = new Tractor()
t.start()
t.start()
println t.engine.starts
println t.rpm()
println Tractor.declaredMethods*.name.findAll { it in ['start', 'rpm'] }.sort()

class Team {
    @Delegate List<String> members = []
}
def tm = new Team()
tm << 'ana'
tm.add('bo')
println tm.size()
println tm instanceof List

interface Pump { void run() }
class P1 implements Pump { void run() { println 'p1' } }
class Owner { @Delegate(interfaces = false) P1 p = new P1() }
println Owner.interfaces*.simpleName
new Owner().run()
2
900
[rpm, start]
2
true
[GroovyObject]
p1

@Immutable and copyWith

@Immutable makes a class’s state unchangeable: it makes the class and fields final, drops the setters, generates toString, equals, hashCode and the constructors, and defends the state with copies. Mutable members get defensive copies on construction and on reading (dates and arrays are cloned, collections are wrapped read-only), and an unsupported type in a property is rejected when the class is constructed. copyWith = true adds a copyWith(Map) method that returns a modified copy.

import groovy.transform.*

@Immutable
class Seed { String name; int year; List<String> tags; Date harvested }

def d = new Date(0)
def s = new Seed('basil', 2026, ['a', 'b'], d)
println "${s.name} ${s.year} ${s.tags} ${s.harvested.time}"
try { s.name = 'x' } catch (e) { println e.class.simpleName }
try { s.tags << 'c' } catch (e) { println e.class.simpleName }
println s.harvested.is(s.harvested)
d.time = 5000
println s.harvested.time
println java.lang.reflect.Modifier.isFinal(Seed.getModifiers())
println Seed.declaredFields.findAll { !it.synthetic }.collect { "${it.name}:${java.lang.reflect.Modifier.isFinal(it.modifiers)}" }

@Immutable(copyWith = true)
class Pt { int x; int y }
def p = new Pt(1, 2)
def q = p.copyWith(y: 9)
println "$p $q"
println p.is(p.copyWith(x: 1))
basil 2026 [a, b] 0
ReadOnlyPropertyException
UnsupportedOperationException
false
0
true
[name:true, year:true, tags:true, harvested:true]
Pt(1, 2) Pt(1, 9)
true

A property whose type the transformation does not recognise as immutable is refused at construction. The type can be declared safe with knownImmutableClasses on @Immutable, or the class itself marked with @KnownImmutable, covered below. An immutable class gets a no-argument constructor, a map constructor and the full tuple constructor; default values fill properties that the map omits, and the constructor list printed by the last example shows that no overload for a partially filled positional argument list is generated.

import groovy.transform.*

class Thing { }
try {
    new GroovyShell().evaluate '''
        @groovy.transform.Immutable class Bad { Thing t }
        class Thing {}
        new Bad(new Thing())'''
} catch (e) {
    println e.class.simpleName
    println e.message.readLines()[0]
}

@Immutable(knownImmutableClasses = [java.io.File])
class Holder { File f }
println new Holder(new File('a')).f

@Immutable
class Hose {
    String id
    int length = 10
}
println new Hose('h', 10)
println new Hose(id: 'h2')
println Hose.constructors.collect { it.parameterTypes*.simpleName }
RuntimeException
Unsupported type (Thing) found for field 't' while constructing immutable class Bad.
a
Hose(h, 10)
Hose(h2, 10)
[[], [Map], [String, int]]

@Memoized

@Memoized caches the results of a method keyed by its arguments, so a repeat call with the same arguments returns the stored value without running the body. maxCacheSize bounds the cache (evicting least-recently used entries), and protectedCacheSize keeps a number of entries safe from garbage collection. The method must be a side-effect-free function of its arguments to be a good candidate. A method with no parameters is cached once. The closure-level equivalent is on Functional Programming.

import groovy.transform.*

class Calc {
    static int runs = 0
    @Memoized int slow(int n) { runs++; n * n }
    @Memoized(maxCacheSize = 2) int bounded(int n) { runs++; n }
    @Memoized String name() { runs++; 'x' }
}
def c = new Calc()
c.slow(3); c.slow(3); c.slow(4)
println Calc.runs
Calc.runs = 0
c.bounded(1); c.bounded(2); c.bounded(3); c.bounded(1)
println Calc.runs
Calc.runs = 0
c.name(); c.name()
println Calc.runs
println Calc.declaredMethods*.name.findAll { it.contains('slow') }.sort()
2
4
1
[memoizedMethodPriv$slowint, slow]

@TailRecursive

The JVM does not remove tail calls, so a deep recursion throws StackOverflowError. @TailRecursive rewrites a method whose recursive call is in tail position into a loop. The method must be a real tail call: the recursive call is the last thing evaluated, with nothing left to do with its result.

import groovy.transform.*

@TailRecursive
long sum(long n, long acc = 0) { n == 0 ? acc : sum(n - 1, acc + n) }
println sum(100000)

def plain
plain = { long n, long acc -> n == 0 ? acc : plain(n - 1, acc + n) }
try { plain(100000, 0) } catch (StackOverflowError e) { println 'StackOverflowError' }

@TailRecursive
int fact(int n, int acc = 1) { n <= 1 ? acc : fact(n - 1, acc * n) }
println fact(10)
5000050000
StackOverflowError
3628800

The closure version overflows because only methods are rewritten. Closures can use trampoline() instead, described on Functional Programming.

@Singleton

@Singleton makes the constructor private and adds a static instance property. By default the instance is created when the class loads; lazy = true creates it on first access, with double-checked locking. Explicit constructors are rejected unless strict = false, which lets the class declare its own.

import groovy.transform.*

@Singleton
class Registry { final List<String> items = [] }
Registry.instance.items << 'a'
println Registry.instance.items
assert Registry.instance.is(Registry.instance)
try { new Registry() } catch (e) { println e.class.simpleName + ': ' + e.message }

@Singleton(lazy = true)
class LazyReg {
    static int created = 0
    int seq = ++created
}
println LazyReg.created
LazyReg.instance
println LazyReg.created

@Singleton(strict = false)
class Loose {
    String tag
    Loose() { tag = 'custom ctor' }
}
println Loose.instance.tag
println new Loose().tag
[a]
RuntimeException: Can't instantiate singleton Registry. Use Registry.instance
0
1
custom ctor
custom ctor

@KnownImmutable

@KnownImmutable declares that a class is immutable without making it so: it is a marker that tells @Immutable that a property of that type needs no defensive copy and is not an unsupported type. Use it on your own value classes that the transformation cannot prove immutable (for example ones with a final field of a type it does not recognise).

import groovy.transform.*

@KnownImmutable
class Frozen {
    final String v
    Frozen(String v) { this.v = v }
}

@Immutable
class UsesFrozen { Frozen f }
println new UsesFrozen(new Frozen('a')).f.v
a

Choosing between them

Need Use

A plain value class

@Canonical, or the three annotations separately when options differ

A value class that cannot change

@Immutable, with copyWith = true for updated copies

A fluent construction API

@Builder with the strategy that fits; @NamedVariant for a method that is called with names

Reuse by composition

@Delegate (traits are on Traits)

A cache or a deep recursion

@Memoized or @TailRecursive

One shared instance

@Singleton, preferably lazy = true

Where the book is outdated

The book this guide was checked against, the first edition of Groovy in Action, covers Groovy 1.0 and predates every transformation on this page: AST transformations arrived in Groovy 1.6. According to the official release notes, the set then grew release by release: @Delegate, @Immutable, @Lazy, @Singleton and @Mixin in 1.6; @TupleConstructor and @Canonical in 1.8; @Memoized in 2.2; @Builder and @Sortable in 2.3; @MapConstructor, @AutoImplement and @NamedVariant in 2.5; @NullCheck in 3.0; and @RecordType and @POJO in 4.0. @POJO and @RecordType only make sense against modern JDKs. The early transformations also gained options over time (for example copyWith on @Immutable), so option lists in older texts written for Groovy 1.x and 2.x are incomplete. @Mixin is deprecated in favour of traits.