Groovy @Immutable Annotation – Create Immutable Objects with 10+ Examples

Groovy immutable objects with @Immutable annotation to create immutable objects with 10+ examples. Defensive copies, known types, copyWith, and thread-safe value objects.

“Immutable objects are always thread-safe. If you can make your objects immutable, you eliminate an entire category of concurrency bugs.”

Effective Java, Joshua Bloch

Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate | Reading Time: 18 minutes

Creating truly immutable objects in Java requires a checklist: final class, final private fields, no setters, defensive copies in constructors and getters. Miss any step, and your “immutable” object has a hole. The groovy immutable approach via the @Immutable annotation does all of this automatically with a single line – final fields, defensive copies, proper equals()/hashCode(), and both tuple and map constructors.

The groovy @Immutable annotation is one of Groovy’s most useful AST transformations. It generates a complete immutable value class at compile time – final fields, defensive copies, proper equals()/hashCode(), a readable toString(), and both tuple and map constructors. If you’ve been following our Groovy AST Transformations post, @Immutable is one of the most powerful applications of that concept.

In this tutorial, you’ll see 10+ practical examples of groovy immutable objects – from simple value types to complex nested structures, the copyWith pattern, and real-world use cases like configuration objects and domain events. Every example is tested on Groovy 5.x with verified output.

According to the official Groovy documentation, the @Immutable annotation instructs the compiler to execute an AST transformation which adds the necessary getters, constructors, equals, hashCode, and other helper methods that are typically written when creating immutable classes.

What Is @Immutable in Groovy?

The @Immutable annotation marks a class as an immutable value object. At compile time, Groovy generates all the code needed to ensure the object cannot be modified after creation. Here’s exactly what it does under the hood:

  • Makes the class final (cannot be extended)
  • Makes all properties final (cannot be reassigned after construction)
  • Generates a tuple constructor (positional arguments)
  • Generates a map-based constructor (named arguments)
  • Generates equals() and hashCode() based on all properties
  • Generates a toString() method
  • Creates defensive copies of mutable fields (Lists, Maps, Dates, etc.)
  • Returns unmodifiable wrappers from collection getters
  • Clones Date fields in getters to prevent mutation through returned references

All of this happens at compile time with zero runtime overhead. The generated bytecode is identical to what you’d write by hand – just without the tedium and without the risk of forgetting a step.

Quick Reference Table

OptionDefaultDescription
copyWithfalseGenerates a copyWith() method for creating modified copies
knownImmutableClasses[]Additional classes to treat as immutable
knownImmutables[]Field names to treat as immutable
includeSuperPropertiesfalseInclude parent class properties
allPropertiestrueInclude JavaBean-style properties
allNamesfalseInclude fields with leading underscore in constructor

Known immutable types (built-in): All primitive wrappers, String, BigDecimal, BigInteger, URL, URI, UUID, Class, enums, and other @Immutable classes.

10 Practical Examples

Example 1: Basic @Immutable Class

What we’re doing: Creating a simple immutable value class with @Immutable.

Example 1: Basic @Immutable

import groovy.transform.Immutable

@Immutable
class Point {
    int x
    int y
}

// Tuple constructor
def p1 = new Point(10, 20)
println "Tuple: ${p1}"

// Map constructor
def p2 = new Point(x: 30, y: 40)
println "Map: ${p2}"

// Equals and hashCode generated automatically
def p3 = new Point(10, 20)
println "p1 == p3: ${p1 == p3}"
println "p1.hashCode() == p3.hashCode(): ${p1.hashCode() == p3.hashCode()}"

// Works in Sets and as Map keys
def points = [p1, p2, p3] as Set
println "Unique points: ${points.size()}"

// Cannot modify after creation
try {
    p1.x = 99
} catch (ReadOnlyPropertyException e) {
    println "Cannot modify: ${e.message}"
}

Output

Tuple: Point(10, 20)
Map: Point(30, 40)
p1 == p3: true
p1.hashCode() == p3.hashCode(): true
Unique points: 2
Cannot modify: Cannot set readonly property: x for class: Point

What happened here: With just @Immutable, we get a fully immutable class. Both constructor styles work, equality is value-based (two points with the same x and y are equal), and attempting to modify any property throws a ReadOnlyPropertyException. This is everything Java developers wish they had without writing 50 lines of boilerplate.

Example 2: @Immutable with Collections

What we’re doing: Showing how @Immutable handles List and Map fields with defensive copies.

Example 2: @Immutable with Collections

import groovy.transform.Immutable

@Immutable
class Team {
    String name
    List<String> members
    Map<String, String> roles
}

def membersList = ['Alice', 'Bob', 'Charlie']
def rolesMap = [Alice: 'Lead', Bob: 'Dev', Charlie: 'QA']

def team = new Team(
    name: 'Alpha',
    members: membersList,
    roles: rolesMap
)
println "Team: ${team}"

// Original list mutation doesn't affect the immutable object
membersList << 'Dave'
println "Original list: ${membersList}"
println "Team members: ${team.members}"

// Cannot modify the team's internal list
try {
    team.members << 'Eve'
} catch (UnsupportedOperationException e) {
    println "Cannot modify members: list is unmodifiable"
}

// Cannot modify the team's internal map
try {
    team.roles['Dave'] = 'Intern'
} catch (UnsupportedOperationException e) {
    println "Cannot modify roles: map is unmodifiable"
}

// Verify defensive copy
println "Original list size: ${membersList.size()}"
println "Team members size: ${team.members.size()}"

Output

Team: Team(Alpha, [Alice, Bob, Charlie], [Alice:Lead, Bob:Dev, Charlie:QA])
Original list: [Alice, Bob, Charlie, Dave]
Team members: [Alice, Bob, Charlie]
Cannot modify members: list is unmodifiable
Cannot modify roles: map is unmodifiable
Original list size: 4
Team members size: 3

What happened here: This is where @Immutable really shines. It makes a defensive copy of the list and map during construction, so mutating the original list after creating the team has no effect. The getters return unmodifiable wrappers, so callers can’t add or remove elements through the returned references. This two-layer protection (copy on construction + unmodifiable on access) is exactly what you need for true immutability.

Example 3: copyWith – Creating Modified Copies

What we’re doing: Using copyWith to create new instances with some fields changed.

Example 3: copyWith

import groovy.transform.Immutable

@Immutable(copyWith = true)
class Config {
    String host
    int port
    boolean ssl
    String database
    int maxConnections
}

def base = new Config(
    host: 'localhost',
    port: 5432,
    ssl: false,
    database: 'mydb',
    maxConnections: 10
)
println "Base: ${base}"

// Create a production version with some changes
def prod = base.copyWith(
    host: 'db.production.com',
    ssl: true,
    maxConnections: 100
)
println "Prod: ${prod}"

// Create a test version
def test = base.copyWith(database: 'test_db')
println "Test: ${test}"

// Chain copies
def staging = base
    .copyWith(host: 'staging.internal')
    .copyWith(database: 'stage_db')
    .copyWith(ssl: true)
println "Staging: ${staging}"

// Original is unchanged
println "Base still: ${base}"
println "All different objects: ${[base, prod, test, staging].unique { System.identityHashCode(it) }.size() == 4}"

Output

Base: Config(localhost, 5432, false, mydb, 10)
Prod: Config(db.production.com, 5432, true, mydb, 100)
Test: Config(localhost, 5432, false, test_db, 10)
Staging: Config(staging.internal, 5432, true, stage_db, 10)
Base still: Config(localhost, 5432, false, mydb, 10)
All different objects: true

What happened here: Since immutable objects can’t be modified, you need a way to create variations. The copyWith option generates a method that creates a new instance with specified fields changed while keeping everything else the same. This is the “with” pattern common in functional programming – you never mutate, you always create a new copy. You can even chain copyWith calls for multiple changes.

Example 4: Nested @Immutable Classes

What we’re doing: Building deeply immutable structures where immutable objects contain other immutable objects.

Example 4: Nested @Immutable

import groovy.transform.Immutable

@Immutable
class Address {
    String street
    String city
    String state
    String zip
}

@Immutable
class ContactInfo {
    String email
    String phone
    Address address
}

@Immutable
class Employee {
    String name
    String title
    ContactInfo contact
    List<String> skills
}

def emp = new Employee(
    name: 'Alice Johnson',
    title: 'Senior Developer',
    contact: new ContactInfo(
        email: 'alice@company.com',
        phone: '555-1234',
        address: new Address(
            street: '123 Tech Ave',
            city: 'San Francisco',
            state: 'CA',
            zip: '94105'
        )
    ),
    skills: ['Groovy', 'Java', 'Kotlin']
)

println "Name: ${emp.name}"
println "Email: ${emp.contact.email}"
println "City: ${emp.contact.address.city}"
println "Skills: ${emp.skills}"

// Everything is deeply immutable
try {
    emp.contact.address.city = 'Los Angeles'
} catch (ReadOnlyPropertyException e) {
    println "Cannot modify nested: ${e.message}"
}

// Two employees with same data are equal
def emp2 = new Employee(
    name: 'Alice Johnson',
    title: 'Senior Developer',
    contact: new ContactInfo(
        email: 'alice@company.com',
        phone: '555-1234',
        address: new Address('123 Tech Ave', 'San Francisco', 'CA', '94105')
    ),
    skills: ['Groovy', 'Java', 'Kotlin']
)
println "emp == emp2: ${emp == emp2}"

Output

Name: Alice Johnson
Email: alice@company.com
City: San Francisco
Skills: [Groovy, Java, Kotlin]
Cannot modify nested: Cannot set readonly property: city for class: Address
emp == emp2: true

What happened here: When one @Immutable class contains another, the entire structure is deeply immutable. Groovy recognizes that Address and ContactInfo are also @Immutable, so it doesn’t need to make defensive copies of them – immutable objects are already safe to share. The equality check goes deep too – two employees are equal only if all nested objects are equal.

Example 5: @Immutable with Date Fields

What we’re doing: Showing how @Immutable protects against Date mutation – a classic Java pitfall.

Example 5: @Immutable with Dates

import groovy.transform.Immutable

@Immutable
class Event {
    String name
    Date startDate
    Date endDate
}

def start = new Date()
def end = start + 7  // 7 days later

def conference = new Event(
    name: 'GroovyConf 2026',
    startDate: start,
    endDate: end
)
println "Event: ${conference.name}"
println "Start: ${conference.startDate.format('yyyy-MM-dd')}"
println "End: ${conference.endDate.format('yyyy-MM-dd')}"

// Mutate the original Date object
start.time = 0  // Set to epoch
println "\nAfter mutating original date:"
println "Original start: ${start}"
println "Event start: ${conference.startDate.format('yyyy-MM-dd')}"
println "Still safe! Defensive copy was made."

// The getter returns a copy too
def retrieved = conference.startDate
retrieved.time = 0
println "\nAfter mutating retrieved date:"
println "Retrieved: ${retrieved}"
println "Event start: ${conference.startDate.format('yyyy-MM-dd')}"
println "Still safe! Getter returns a copy."

Output

Event: GroovyConf 2026
Start: 2026-03-08
End: 2026-03-15

After mutating original date:
Original start: Thu Jan 01 00:00:00 UTC 1970
Event start: 2026-03-08
Still safe! Defensive copy was made.

After mutating retrieved date:
Retrieved: Thu Jan 01 00:00:00 UTC 1970
Event start: 2026-03-08
Still safe! Getter returns a copy.

What happened here: Java’s Date class is mutable – you can change its internal time value at any point. This is a notorious source of bugs. @Immutable handles this with two layers of protection: it clones the Date during construction (so mutating the original doesn’t affect the object), and it clones again on every getter call (so callers can’t mutate the internal state through the returned reference). This double-copy strategy ensures true immutability even with inherently mutable types.

Example 6: knownImmutableClasses – Custom Immutable Types

What we’re doing: Telling @Immutable about third-party classes that are immutable but not annotated.

Example 6: knownImmutableClasses

import groovy.transform.Immutable

// A class from a third-party library (we can't annotate it)
class Currency {
    final String code
    final String symbol
    final int decimalPlaces

    Currency(String code, String symbol, int decimalPlaces) {
        this.code = code
        this.symbol = symbol
        this.decimalPlaces = decimalPlaces
    }

    String toString() { "${symbol} (${code})" }
}

@Immutable(knownImmutableClasses = [Currency])
class Money {
    BigDecimal amount
    Currency currency
}

def usd = new Currency('USD', '$', 2)
def price = new Money(amount: 29.99, currency: usd)
println "Price: ${price}"
println "Currency: ${price.currency}"

// Multiple known immutable classes
class Coordinate {
    final double lat
    final double lon

    Coordinate(double lat, double lon) {
        this.lat = lat
        this.lon = lon
    }

    String toString() { "(${lat}, ${lon})" }
}

@Immutable(knownImmutableClasses = [Currency, Coordinate])
class Store {
    String name
    Coordinate location
    Currency localCurrency
}

def store = new Store(
    name: 'Downtown Shop',
    location: new Coordinate(37.7749, -122.4194),
    localCurrency: usd
)
println "Store: ${store}"
println "Location: ${store.location}"

Output

Price: Money(29.99, $ (USD))
Currency: $ (USD)
Store: Store(Downtown Shop, (37.7749, -122.4194), $ (USD))
Location: (37.7749, -122.4194)

What happened here: Groovy only knows about certain types being immutable (String, Integer, BigDecimal, etc. and other @Immutable classes). When you use a third-party class that’s effectively immutable but doesn’t have the annotation, you need to tell Groovy about it using knownImmutableClasses. Without this, Groovy would refuse to compile or would try to make defensive copies of objects that don’t support cloning.

Example 7: knownImmutables – Field-Level Override

What we’re doing: Marking specific fields as immutable by name, regardless of their declared type.

Example 7: knownImmutables

import groovy.transform.Immutable

// When you know a field won't be mutated but its type is mutable
@Immutable(knownImmutables = ['metadata'])
class Document {
    String title
    String content
    Map metadata  // We promise this won't be mutated externally
}

def doc = new Document(
    title: 'Release Notes',
    content: 'Version 2.0 released...',
    metadata: [version: '2.0', author: 'Team']
)
println "Doc: ${doc}"
println "Metadata: ${doc.metadata}"

// Practical use: wrapping framework objects you trust
@Immutable(knownImmutables = ['context', 'headers'])
class HttpRequest {
    String method
    String path
    Map headers
    String body
    Object context  // Framework-managed, effectively immutable
}

def request = new HttpRequest(
    method: 'GET',
    path: '/api/users',
    headers: ['Content-Type': 'application/json', 'Auth': 'Bearer token123'],
    body: '',
    context: 'request-ctx-001'
)
println "Request: ${request.method} ${request.path}"
println "Headers: ${request.headers}"

Output

Doc: Document(Release Notes, Version 2.0 released..., [version:2.0, author:Team])
Metadata: [version:2.0, author:Team]
Request: GET /api/users
Headers: [Content-Type:application/json, Auth:Bearer token123]

What happened here: knownImmutables works at the field level instead of the type level. You specify field names that should be treated as immutable, and Groovy skips defensive copying for those fields. Use this when you have a mutable type that you guarantee won’t be mutated, or when the type doesn’t support cloning. It’s a pragmatic escape hatch – use it carefully, because you’re taking responsibility for immutability of those fields.

Example 8: @Immutable for Domain Events

What we’re doing: Using @Immutable to model domain events – a perfect real-world use case.

Example 8: Domain Events

import groovy.transform.Immutable

@Immutable
class OrderPlaced {
    String orderId
    String customerId
    List<String> items
    BigDecimal total
    Date placedAt
}

@Immutable
class PaymentReceived {
    String orderId
    String paymentId
    BigDecimal amount
    String method
    Date receivedAt
}

@Immutable
class OrderShipped {
    String orderId
    String trackingNumber
    String carrier
    Date shippedAt
}

// Create an event log (immutable events are perfect for event sourcing)
def events = []

def order = new OrderPlaced(
    orderId: 'ORD-001',
    customerId: 'CUST-42',
    items: ['Laptop', 'Mouse', 'Keyboard'],
    total: 1249.97,
    placedAt: new Date()
)
events << order

def payment = new PaymentReceived(
    orderId: 'ORD-001',
    paymentId: 'PAY-789',
    amount: 1249.97,
    method: 'Credit Card',
    receivedAt: new Date()
)
events << payment

def shipped = new OrderShipped(
    orderId: 'ORD-001',
    trackingNumber: '1Z999AA10123456784',
    carrier: 'UPS',
    shippedAt: new Date()
)
events << shipped

println "Event Log:"
events.each { event ->
    println "  [${event.class.simpleName}] ${event}"
}

// Events are safely shareable across threads
println "\nOrder items: ${order.items}"
println "Payment amount: \$${payment.amount}"
println "Tracking: ${shipped.trackingNumber}"

Output

Event Log:
  [OrderPlaced] OrderPlaced(ORD-001, CUST-42, [Laptop, Mouse, Keyboard], 1249.97, Sun Mar 08 14:32:07 UTC 2026)
  [PaymentReceived] PaymentReceived(ORD-001, PAY-789, 1249.97, Credit Card, Sun Mar 08 14:32:07 UTC 2026)
  [OrderShipped] OrderShipped(ORD-001, 1Z999AA10123456784, UPS, Sun Mar 08 14:32:07 UTC 2026)

Order items: [Laptop, Mouse, Keyboard]
Payment amount: $1249.97
Tracking: 1Z999AA10123456784

What happened here: Domain events are a perfect fit for @Immutable. Events represent facts that happened in the past – they should never change. Once an order is placed, that event is historical record. Immutable events can be safely stored, transmitted, replayed, and shared across threads without synchronization. This pattern is the foundation of event sourcing and CQRS architectures.

Example 9: @Immutable with Enums and Default Values

What we’re doing: Combining @Immutable with enums and default field values.

Example 9: @Immutable with Enums

import groovy.transform.Immutable

enum Priority { LOW, MEDIUM, HIGH, CRITICAL }
enum Status { OPEN, IN_PROGRESS, RESOLVED, CLOSED }

@Immutable(copyWith = true)
class Ticket {
    String id
    String title
    String description
    Priority priority
    Status status
    List<String> tags
}

// Create a new ticket
def ticket = new Ticket(
    id: 'TICKET-001',
    title: 'Login page broken',
    description: 'Users cannot log in after update',
    priority: Priority.CRITICAL,
    status: Status.OPEN,
    tags: ['auth', 'ui', 'production']
)
println "New ticket: ${ticket}"

// Workflow: update status using copyWith
def inProgress = ticket.copyWith(status: Status.IN_PROGRESS)
println "In progress: ${inProgress}"

def resolved = inProgress.copyWith(status: Status.RESOLVED)
println "Resolved: ${resolved}"

def closed = resolved.copyWith(
    status: Status.CLOSED,
    tags: resolved.tags + 'hotfix'
)
println "Closed: ${closed}"

// Original ticket unchanged
println "\nOriginal still: ${ticket.status}"

// Enum fields are naturally immutable
println "\nPriority: ${ticket.priority}"
println "Is critical: ${ticket.priority == Priority.CRITICAL}"

Output

New ticket: Ticket(TICKET-001, Login page broken, Users cannot log in after update, CRITICAL, OPEN, [auth, ui, production])
In progress: Ticket(TICKET-001, Login page broken, Users cannot log in after update, CRITICAL, IN_PROGRESS, [auth, ui, production])
Resolved: Ticket(TICKET-001, Login page broken, Users cannot log in after update, CRITICAL, RESOLVED, [auth, ui, production])
Closed: Ticket(TICKET-001, Login page broken, Users cannot log in after update, CRITICAL, CLOSED, [auth, ui, production, hotfix])

Original still: OPEN

Priority: CRITICAL
Is critical: true

What happened here: Enums are inherently immutable, so Groovy handles them perfectly in @Immutable classes. This example shows a practical workflow where a ticket moves through states. Each state change creates a new immutable object, preserving the complete history. The copyWith pattern makes this ergonomic – you specify only what changes, and everything else carries forward. This is the functional programming approach to state management.

Example 10: @Immutable as Map Keys and in Collections

What we’re doing: Demonstrating why immutable objects are ideal for use as Map keys and in Sets.

Example 10: @Immutable as Map Keys

import groovy.transform.Immutable

@Immutable
class Coordinate {
    double lat
    double lon
}

@Immutable
class Color {
    int r, g, b

    String hex() {
        String.format('#%02X%02X%02X', r, g, b)
    }
}

// Immutable objects as Map keys - always safe
def locationNames = [:]
locationNames[new Coordinate(37.7749, -122.4194)] = 'San Francisco'
locationNames[new Coordinate(40.7128, -74.0060)] = 'New York'
locationNames[new Coordinate(51.5074, -0.1278)] = 'London'

// Lookup by value (works because equals/hashCode are value-based)
def lookup = new Coordinate(40.7128, -74.0060)
println "Location: ${locationNames[lookup]}"

// Immutable objects in Sets - no duplicate issues
def palette = [] as Set
palette << new Color(255, 0, 0)
palette << new Color(0, 255, 0)
palette << new Color(0, 0, 255)
palette << new Color(255, 0, 0)  // Duplicate!

println "Palette size: ${palette.size()}"
palette.each { println "  ${it.hex()} -> ${it}" }

// Sorting immutable objects
def coordinates = [
    new Coordinate(51.5074, -0.1278),
    new Coordinate(37.7749, -122.4194),
    new Coordinate(40.7128, -74.0060)
]
def sorted = coordinates.sort { it.lat }
println "\nSorted by latitude:"
sorted.each { c ->
    println "  ${locationNames[c] ?: 'Unknown'}: (${c.lat}, ${c.lon})"
}

Output

Location: New York
Palette size: 3
  #FF0000 -> Color(255, 0, 0)
  #00FF00 -> Color(0, 255, 0)
  #0000FF -> Color(0, 0, 255)

Sorted by latitude:
  San Francisco: (37.7749, -122.4194)
  New York: (40.7128, -74.006)
  London: (51.5074, -0.1278)

What happened here: Mutable objects used as Map keys are a classic bug – if you mutate the key after insertion, the entry becomes unreachable. Immutable objects eliminate this risk entirely. The hashCode() can never change because the fields can never change. This makes @Immutable objects perfect for Sets and Map keys. The duplicate Color(255, 0, 0) is correctly detected and rejected by the Set because value-based equality works properly.

Example 11: @Immutable Configuration Objects

What we’re doing: Building a real-world configuration system using immutable objects.

Example 11: Configuration Objects

import groovy.transform.Immutable

@Immutable(copyWith = true)
class DatabaseConfig {
    String host
    int port
    String name
    String user
    int poolSize
    boolean ssl
}

@Immutable(copyWith = true)
class CacheConfig {
    String provider
    int ttlSeconds
    int maxEntries
}

@Immutable(copyWith = true)
class AppConfig {
    String appName
    String environment
    DatabaseConfig database
    CacheConfig cache
    List<String> enabledFeatures
}

// Build default config
def defaultConfig = new AppConfig(
    appName: 'MyService',
    environment: 'development',
    database: new DatabaseConfig('localhost', 5432, 'dev_db', 'dev', 5, false),
    cache: new CacheConfig('memory', 300, 1000),
    enabledFeatures: ['auth', 'logging']
)

// Derive environment-specific configs
def prodConfig = defaultConfig.copyWith(
    environment: 'production',
    database: new DatabaseConfig('db.prod.com', 5432, 'prod_db', 'app_user', 50, true),
    cache: new CacheConfig('redis', 3600, 100000),
    enabledFeatures: ['auth', 'logging', 'metrics', 'rate-limiting']
)

println "Dev Config:"
println "  DB: ${defaultConfig.database.host}:${defaultConfig.database.port}"
println "  Cache: ${defaultConfig.cache.provider} (TTL: ${defaultConfig.cache.ttlSeconds}s)"
println "  Features: ${defaultConfig.enabledFeatures}"

println "\nProd Config:"
println "  DB: ${prodConfig.database.host}:${prodConfig.database.port} (SSL: ${prodConfig.database.ssl})"
println "  Cache: ${prodConfig.cache.provider} (TTL: ${prodConfig.cache.ttlSeconds}s)"
println "  Features: ${prodConfig.enabledFeatures}"

println "\nConfigs are different objects: ${!defaultConfig.is(prodConfig)}"
println "Dev unchanged: ${defaultConfig.environment}"

Output

Dev Config:
  DB: localhost:5432
  Cache: memory (TTL: 300s)
  Features: [auth, logging]

Prod Config:
  DB: db.prod.com:5432 (SSL: true)
  Cache: redis (TTL: 3600s)
  Features: [auth, logging, metrics, rate-limiting]

Configs are different objects: true
Dev unchanged: development

What happened here: Configuration objects are an ideal use case for @Immutable. Once your application loads its config, it should never change. Immutable config objects can be safely shared across threads and services without synchronization. The copyWith pattern makes it easy to derive environment-specific configs from a base template without mutating the original.

Defensive Copies Explained

Defensive copying is the secret sauce that makes @Immutable truly safe. Here’s exactly what happens with different field types:

Defensive Copies in Action

import groovy.transform.Immutable

@Immutable
class SafeContainer {
    String name          // String is immutable - no copy needed
    int count            // Primitives are immutable - no copy needed
    List<String> items   // Mutable! Copy on construct, unmodifiable on access
    Map<String, Integer> scores  // Mutable! Same treatment
    Date created         // Mutable! Clone on construct AND on access
}

// Demonstrate the protection layers
def originalList = ['a', 'b', 'c']
def originalMap = [x: 1, y: 2]
def originalDate = new Date()

def safe = new SafeContainer('test', 42, originalList, originalMap, originalDate)

// Layer 1: Constructor makes copies
originalList << 'd'
originalMap['z'] = 3
originalDate.time = 0
println "Original list: ${originalList}"  // [a, b, c, d]
println "Safe list: ${safe.items}"         // [a, b, c]
println "Original map: ${originalMap}"     // [x:1, y:2, z:3]
println "Safe map: ${safe.scores}"         // [x:1, y:2]

// Layer 2: Getters return unmodifiable/cloned versions
try { safe.items << 'e' } catch (e) { println "List protected: ${e.class.simpleName}" }
try { safe.scores['z'] = 3 } catch (e) { println "Map protected: ${e.class.simpleName}" }

def dateFromGetter = safe.created
dateFromGetter.time = 0
println "Date protected: ${safe.created != dateFromGetter}"

Output

Original list: [a, b, c, d]
Safe list: [a, b, c]
Original map: [x:1, y:2, z:3]
Safe map: [x:1, y:2]
List protected: UnsupportedOperationException
Map protected: UnsupportedOperationException
Date protected: true

The defensive copy strategy varies by type. Strings and primitives are inherently immutable – no copies needed. Lists and Maps are copied on construction and wrapped in Collections.unmodifiableList() / Collections.unmodifiableMap() on access. Dates are cloned both on construction and on every getter call because they have no unmodifiable wrapper.

@Immutable vs @Canonical

Feature@Canonical@Immutable
FieldsMutableFinal (read-only)
ClassCan be extendedFinal (cannot extend)
SettersGeneratedBlocked (throws exception)
Defensive copiesNoYes (Lists, Maps, Dates)
Thread safetyNot guaranteedGuaranteed
Use forMutable data classesValue objects, events, configs
toString/equals/hashCodeYesYes
ConstructorsTuple + MapTuple + Map

Use @Canonical when you need a mutable data class with convenience methods. Use @Immutable when you need a true value object that never changes. As we covered in our AST Transformations post, @Canonical is essentially @ToString + @EqualsAndHashCode + @TupleConstructor, while @Immutable adds finality and defensive copies on top.

Thread Safety and Concurrency

Immutable objects are inherently thread-safe because no thread can modify their state. You never need synchronization, locks, or volatile fields when sharing immutable objects between threads.

Thread Safety

import groovy.transform.Immutable

@Immutable
class SharedConfig {
    String apiUrl
    String apiKey
    int timeout
    List<String> allowedOrigins
}

def config = new SharedConfig(
    apiUrl: 'https://api.example.com',
    apiKey: 'secret-key-123',
    timeout: 30000,
    allowedOrigins: ['https://app.example.com', 'https://admin.example.com']
)

// Safely share across threads - no synchronization needed
def threads = (1..5).collect { threadNum ->
    Thread.start {
        // Each thread reads the same immutable config
        println "Thread ${threadNum}: URL=${config.apiUrl}, Timeout=${config.timeout}, Origins=${config.allowedOrigins.size()}"
    }
}

threads*.join()

println "\nConfig unchanged: ${config}"

Output

Thread 1: URL=https://api.example.com, Timeout=30000, Origins=2
Thread 2: URL=https://api.example.com, Timeout=30000, Origins=2
Thread 3: URL=https://api.example.com, Timeout=30000, Origins=2
Thread 4: URL=https://api.example.com, Timeout=30000, Origins=2
Thread 5: URL=https://api.example.com, Timeout=30000, Origins=2

Config unchanged: SharedConfig(https://api.example.com, secret-key-123, 30000, [https://app.example.com, https://admin.example.com])

Every thread sees the same consistent state, guaranteed. There’s no possibility of a partial update or a race condition. This is why immutable objects are a cornerstone of concurrent programming – they eliminate an entire category of bugs by making shared state impossible to corrupt.

Best Practices

DO:

  • Use @Immutable for value objects, DTOs, events, and configuration
  • Enable copyWith = true when you need to create variations of objects
  • Use knownImmutableClasses for third-party types you trust
  • Prefer @Immutable over manually writing immutable classes – the annotation catches more edge cases
  • Make objects immutable by default; only make them mutable when you have a specific reason

DON’T:

  • Use @Immutable for entities with mutable lifecycle (use @Canonical instead)
  • Abuse knownImmutables for fields that are actually mutable – you’ll get subtle bugs
  • Forget that @Immutable makes the class final – you can’t extend it
  • Store @Immutable objects in frameworks that need mutable beans (like some ORMs)

Common Pitfalls

Pitfall 1: Unknown Mutable Types

Unknown Mutable Types

import groovy.transform.Immutable

// This would fail at compile time:
// @Immutable
// class Broken {
//     String name
//     StringBuilder buffer  // Not a known immutable type!
// }

// Fix: use knownImmutables if you guarantee immutability
@Immutable(knownImmutables = ['buffer'])
class WorksButRisky {
    String name
    StringBuilder buffer
}

// Better fix: don't use mutable types in immutable classes
@Immutable
class WorksCorrectly {
    String name
    String bufferContent  // Store the String, not the StringBuilder
}

def obj = new WorksCorrectly(name: 'test', bufferContent: 'hello world')
println "Safe: ${obj}"

Output

Safe: WorksCorrectly(test, hello world)

If you include a field of a type Groovy doesn’t recognize as immutable, compilation will fail. You can work around it with knownImmutables, but the better approach is to convert mutable types to their immutable equivalents (e.g., StringBuilder to String) before storing them.

Pitfall 2: @Immutable Classes Cannot Be Extended

Cannot Extend @Immutable

import groovy.transform.Immutable

@Immutable
class BaseConfig {
    String name
    int value
}

// This would fail:
// class ExtendedConfig extends BaseConfig {
//     String extra
// }
// Error: Cannot extend final class BaseConfig

// Instead, use composition
@Immutable
class ExtendedConfig {
    BaseConfig base
    String extra
}

def config = new ExtendedConfig(
    base: new BaseConfig('setting', 42),
    extra: 'additional'
)
println "Extended: ${config}"
println "Base name: ${config.base.name}"

Output

Extended: ExtendedConfig(BaseConfig(setting, 42), additional)
Base name: setting

@Immutable makes the class final because allowing subclasses would break immutability – a subclass could add mutable fields. If you need to extend behavior, use composition (containing the immutable object as a field) rather than inheritance. This is another case where the “composition over inheritance” principle applies, and Groovy traits can help add behavior without subclassing.

Conclusion

The groovy @Immutable annotation is one of the most valuable tools in your Groovy toolkit. It creates truly immutable objects with a single line – handling final fields, defensive copies, proper equality, readable toString, and both constructor styles. No more manually writing boilerplate that’s easy to get wrong.

We covered the full range of @Immutable capabilities: basic value types, collection protection, the copyWith pattern for creating variations, nested immutable structures, Date handling, custom immutable types, and real-world patterns like domain events and configuration objects.

If you came here from our Groovy AST Transformations post, you’ve now seen one of the most practical applications of compile-time metaprogramming. Next up, check out Groovy @Builder for another useful AST transformation that makes object construction even more flexible.

Summary

  • @Immutable generates a complete immutable class with final fields, defensive copies, and proper equals/hashCode
  • Collections are copied on construction and returned as unmodifiable; Dates are cloned on every access
  • Use copyWith = true for the functional “with” pattern to create modified copies
  • Immutable objects are inherently thread-safe – no synchronization needed
  • Perfect for value objects, events, configurations, and Map keys

If you also work with build tools, CI/CD pipelines, or cloud CLIs, check out Command Playground to practice 105+ CLI tools directly in your browser — no install needed.

Up next: Groovy @Builder – Builder Pattern Made Easy

Frequently Asked Questions

What does @Immutable do in Groovy?

The @Immutable annotation creates a complete immutable class at compile time. It makes the class final, all fields final, generates defensive copies for mutable types (Lists, Maps, Dates), creates proper equals() and hashCode() methods, generates toString(), and provides both tuple and map constructors. After construction, no property can be modified.

What is the difference between @Immutable and @Canonical in Groovy?

@Canonical creates a mutable data class with toString, equals, hashCode, and constructors. @Immutable does the same but also makes all fields final, the class final, and creates defensive copies to prevent any modification after construction. Use @Canonical for mutable classes and @Immutable for value objects that should never change.

How do I modify an @Immutable object in Groovy?

You cannot modify an @Immutable object – that’s the point. Instead, create a new object with the desired changes. Enable copyWith = true in the annotation, then use object.copyWith(field: newValue) to create a new instance with specific fields changed. The original object remains unchanged.

Are Groovy @Immutable objects thread-safe?

Yes, absolutely. Immutable objects are inherently thread-safe because their state cannot change after construction. Multiple threads can read the same immutable object simultaneously without any synchronization, locks, or volatile fields. This makes @Immutable objects ideal for shared configuration, caches, and message passing between threads.

Why does @Immutable fail with my custom class as a field type?

Groovy only recognizes certain types as immutable: primitives, String, BigDecimal, BigInteger, URL, URI, UUID, enums, and other @Immutable classes. If your field uses a type outside this list, use knownImmutableClasses = [YourClass] to tell Groovy it’s safe, or use knownImmutables = ['fieldName'] to mark specific fields as immutable.

Previous in Series: Groovy AST Transformations – Compile-Time Code Generation

Next in Series: Groovy @Builder – Builder Pattern Made Easy

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 *