Groovy builder pattern with the @Builder annotation with 10+ examples. DefaultStrategy, SimpleStrategy, ExternalStrategy, InitializerStrategy explained.
“The builder pattern separates the construction of a complex object from its representation. Groovy’s @Builder does this without writing a single builder class.”
Gang of Four, Design Patterns
Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate | Reading Time: 19 minutes
When a class has many fields – some required, some optional – constructors become unwieldy and map-based construction loses type safety. The groovy builder pattern via the @Builder annotation solves this by generating a complete fluent builder at compile time: Person.builder().name('Alice').age(30).build(). No hand-written builder classes, no boilerplate.
The groovy @Builder annotation is an AST transformation that generates a builder class at compile time. It supports four different strategies, each suited to different use cases. A classic external builder, a simple fluent setter chain, a type-safe initializer that enforces all required fields at compile time – @Builder has a strategy for each.
If you’ve been following our series on Groovy AST Transformations, you already know how Groovy generates code at compile time. And if you’ve read about Groovy @Immutable, you know the value of well-constructed objects. @Builder bridges these concepts – it gives you a clean API for constructing complex objects, including immutable ones.
According to the official Groovy documentation on compile-time metaprogramming, the @Builder AST transformation provides multiple strategies for implementing the builder design pattern, each generating different code structures to suit different coding styles and requirements.
Table of Contents
What Is @Builder in Groovy?
The @Builder annotation, located in the groovy.transform.builder package, generates builder code at compile time. It supports four strategies:
- DefaultStrategy: Creates an inner builder class with a
builder()static method andbuild()instance method - SimpleStrategy: Adds fluent setters directly to the class (no separate builder class)
- ExternalStrategy: Creates the builder as a separate class (useful when you can’t modify the target class)
- InitializerStrategy: Generates a type-safe builder that enforces all required fields at compile time
Each strategy has its sweet spot. The default strategy is the most common and familiar. SimpleStrategy is lightest. ExternalStrategy works with third-party classes. InitializerStrategy provides compile-time safety that catches missing fields before your code runs.
Quick Reference Table
| Option | Default | Description |
|---|---|---|
builderStrategy | DefaultStrategy | Which builder pattern strategy to use |
prefix | "" | Prefix for setter methods (e.g., “set” gives setName()) |
builderClassName | "[Class]Builder" | Name of the generated builder class |
buildMethodName | "build" | Name of the terminal build method |
builderMethodName | "builder" | Name of the static factory method |
includes | all fields | Fields to include in the builder |
excludes | [] | Fields to exclude from the builder |
includeSuperProperties | false | Include parent class properties |
allNames | false | Include fields with leading underscore |
10 Practical Examples
Example 1: Basic @Builder with DefaultStrategy
What we’re doing: Using the default builder strategy to create objects with a fluent API.
Example 1: Basic @Builder
import groovy.transform.builder.Builder
import groovy.transform.ToString
@Builder
@ToString(includeNames = true)
class Person {
String firstName
String lastName
int age
String email
String phone
}
// Fluent builder API
def person = Person.builder()
.firstName('Alice')
.lastName('Johnson')
.age(30)
.email('alice@example.com')
.phone('555-1234')
.build()
println person
// Set only some fields (others get default values)
def partial = Person.builder()
.firstName('Bob')
.lastName('Smith')
.build()
println partial
// Builder is reusable
def builder = Person.builder()
.lastName('Williams')
.age(25)
def person1 = builder.firstName('Charlie').build()
def person2 = builder.firstName('Diana').email('diana@test.com').build()
println person1
println person2
Output
Person(firstName:Alice, lastName:Johnson, age:30, email:alice@example.com, phone:555-1234) Person(firstName:Bob, lastName:Smith, age:0, email:null, phone:null) Person(firstName:Charlie, lastName:Williams, age:25, email:null, phone:null) Person(firstName:Diana, lastName:Williams, age:25, email:diana@test.com, phone:null)
What happened here: The @Builder annotation generated a PersonBuilder inner class with setter methods for each field. Each setter returns the builder itself, enabling method chaining. The build() method creates the final Person instance. Fields you don’t set get their default values (null for objects, 0 for ints). This is the classic builder pattern – without writing any builder code yourself.
Example 2: SimpleStrategy – Fluent Setters on the Class
What we’re doing: Using SimpleStrategy to add fluent setters directly to the class without a separate builder.
Example 2: SimpleStrategy
import groovy.transform.builder.Builder
import groovy.transform.builder.SimpleStrategy
import groovy.transform.ToString
@Builder(builderStrategy = SimpleStrategy, prefix = 'set')
@ToString(includeNames = true)
class HttpRequest {
String method
String url
Map<String, String> headers
String body
int timeout
}
// Fluent setter chain directly on the object
def request = new HttpRequest()
.setMethod('POST')
.setUrl('https://api.example.com/users')
.setHeaders(['Content-Type': 'application/json', 'Auth': 'Bearer token123'])
.setBody('{"name": "Alice", "age": 30}')
.setTimeout(30000)
println request
// Without prefix - method names match field names
@Builder(builderStrategy = SimpleStrategy)
@ToString(includeNames = true)
class QueryParams {
String table
List<String> columns
String whereClause
String orderBy
int limit
}
def query = new QueryParams()
.table('users')
.columns(['id', 'name', 'email'])
.whereClause("active = true")
.orderBy('name ASC')
.limit(100)
println query
Output
HttpRequest(method:POST, url:https://api.example.com/users, headers:[Content-Type:application/json, Auth:Bearer token123], body:{"name": "Alice", "age": 30}, timeout:30000)
QueryParams(table:users, columns:[id, name, email], whereClause:active = true, orderBy:name ASC, limit:100)
What happened here: SimpleStrategy doesn’t create a separate builder class. Instead, it modifies the setters on the class itself to return this, enabling chaining. With prefix = 'set', the methods are named setMethod(), setUrl(), etc. Without a prefix, they’re just method(), url(), etc. This is the lightest-weight builder strategy – no inner class, no build() method, just fluent setters.
Example 3: ExternalStrategy – Building Third-Party Classes
What we’re doing: Creating a builder for a class you can’t modify (e.g., from a library).
Example 3: ExternalStrategy
import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy
import groovy.transform.ToString
// Imagine this class comes from a third-party library
@ToString(includeNames = true)
class DatabaseConnection {
String host
int port
String database
String username
String password
boolean ssl
int poolSize
}
// Create an external builder for it
@Builder(builderStrategy = ExternalStrategy, forClass = DatabaseConnection)
class DatabaseConnectionBuilder {}
// Use the external builder
def conn = new DatabaseConnectionBuilder()
.host('db.production.com')
.port(5432)
.database('prod_db')
.username('app_user')
.password('s3cret')
.ssl(true)
.poolSize(20)
.build()
println conn
// Another external builder for a different class
@ToString(includeNames = true)
class EmailMessage {
String from
String to
String subject
String body
List<String> cc
boolean html
}
@Builder(builderStrategy = ExternalStrategy, forClass = EmailMessage)
class EmailBuilder {}
def email = new EmailBuilder()
.from('noreply@company.com')
.to('alice@example.com')
.subject('Welcome!')
.body('<h1>Hello Alice</h1>')
.cc(['bob@example.com', 'charlie@example.com'])
.html(true)
.build()
println email
Output
DatabaseConnection(host:db.production.com, port:5432, database:prod_db, username:app_user, password:s3cret, ssl:true, poolSize:20) EmailMessage(from:noreply@company.com, to:alice@example.com, subject:Welcome!, body:<h1>Hello Alice</h1>, cc:[bob@example.com, charlie@example.com], html:true)
What happened here: ExternalStrategy creates a builder for a class you don’t own. The forClass parameter tells Groovy which class to build for. The builder reads the target class’s properties and generates setter methods for each one. This is invaluable when working with third-party libraries that don’t provide a builder themselves. You create a tiny empty class, annotate it, and get a full builder for free.
Example 4: InitializerStrategy – Compile-Time Safety
What we’re doing: Using InitializerStrategy to enforce that all fields are set at compile time.
Example 4: InitializerStrategy
import groovy.transform.builder.Builder
import groovy.transform.builder.InitializerStrategy
import groovy.transform.ToString
import groovy.transform.CompileStatic
@ToString(includeNames = true)
@Builder(builderStrategy = InitializerStrategy)
class ServerConfig {
String host
int port
String protocol
}
// All fields must be set - enforced at compile time with @CompileStatic
def config = ServerConfig.createInitializer()
.host('api.example.com')
.port(443)
.protocol('https')
.create()
println config
// With CompileStatic, missing fields cause a compile error:
// @CompileStatic
// void broken() {
// def c = ServerConfig.createInitializer()
// .host('test')
// .port(80)
// // .protocol() is missing - won't compile!
// .create()
// }
// Without @CompileStatic, it works but may fail at runtime
def partial = ServerConfig.createInitializer()
.host('localhost')
.port(8080)
.protocol('http')
.create()
println partial
Output
ServerConfig(host:api.example.com, port:443, protocol:https) ServerConfig(host:localhost, port:8080, protocol:http)
What happened here: InitializerStrategy is the most type-safe builder strategy. It uses generics to track which fields have been set, and with @CompileStatic, the compiler rejects code that doesn’t set all required fields. The method names are createInitializer() and create() instead of builder() and build(). This is the ultimate compile-time safety – you literally cannot forget a required field.
Example 5: @Builder with includes and excludes
What we’re doing: Controlling which fields appear in the builder using includes and excludes.
Example 5: includes and excludes
import groovy.transform.builder.Builder
import groovy.transform.ToString
// Exclude auto-generated fields from the builder
@Builder(excludes = ['id', 'createdAt', 'updatedAt'])
@ToString(includeNames = true)
class Article {
String id = UUID.randomUUID().toString().take(8)
String title
String content
String author
List<String> tags
Date createdAt = new Date()
Date updatedAt = new Date()
}
def article = Article.builder()
.title('Understanding Groovy Builders')
.content('The builder pattern is...')
.author('Alice')
.tags(['groovy', 'patterns', 'tutorial'])
.build()
println article
println "ID (auto): ${article.id}"
println "Created (auto): ${article.createdAt.format('yyyy-MM-dd')}"
// Include only specific fields
@Builder(includes = ['name', 'email'])
@ToString(includeNames = true)
class QuickContact {
String name
String email
String phone
String address
String company
}
def contact = QuickContact.builder()
.name('Bob')
.email('bob@test.com')
.build()
println contact
Output
Article(id:a3f2b1c9, title:Understanding Groovy Builders, content:The builder pattern is..., author:Alice, tags:[groovy, patterns, tutorial], createdAt:Sun Mar 08 14:32:07 UTC 2026, updatedAt:Sun Mar 08 14:32:07 UTC 2026) ID (auto): a3f2b1c9 Created (auto): 2026-03-08 QuickContact(name:Bob, email:bob@test.com, phone:null, address:null, company:null)
What happened here: The excludes option removes fields from the builder that should be auto-generated (like IDs and timestamps). The includes option is the inverse – only the named fields get builder methods. Both options help keep your builder API clean and focused. Users of the builder only see the fields they should set.
Example 6: Custom Builder Method and Class Names
What we’re doing: Customizing the names of the builder class, builder method, and build method.
Example 6: Custom Names
import groovy.transform.builder.Builder
import groovy.transform.ToString
@Builder(
builderClassName = 'Creator',
builderMethodName = 'create',
buildMethodName = 'done',
prefix = 'with'
)
@ToString(includeNames = true)
class Widget {
String name
String color
int width
int height
boolean visible
}
// Custom API: Widget.create() ... .done()
def widget = Widget.create()
.withName('Submit Button')
.withColor('blue')
.withWidth(120)
.withHeight(40)
.withVisible(true)
.done()
println widget
// Another example with "of" style
@Builder(
builderMethodName = 'of',
buildMethodName = 'make',
prefix = ''
)
@ToString(includeNames = true)
class NotificationConfig {
String channel
String message
String severity
boolean persistent
}
def notification = NotificationConfig.of()
.channel('#alerts')
.message('Server CPU > 90%')
.severity('warning')
.persistent(true)
.make()
println notification
Output
Widget(name:Submit Button, color:blue, width:120, height:40, visible:true) NotificationConfig(channel:#alerts, message:Server CPU > 90%, severity:warning, persistent:true)
What happened here: Every aspect of the builder API is customizable. The prefix option adds a prefix to setter methods (e.g., “with” gives withName() instead of name()). The builderMethodName and buildMethodName options let you match your team’s naming conventions. This flexibility means you can make the builder API read like natural language.
Example 7: @Builder with Inheritance
What we’re doing: Using @Builder with class hierarchies using includeSuperProperties.
Example 7: @Builder with Inheritance
import groovy.transform.builder.Builder
import groovy.transform.ToString
@ToString(includeNames = true)
class Vehicle {
String make
String model
int year
}
@Builder(includeSuperProperties = true)
@ToString(includeNames = true, includeSuper = true)
class Car extends Vehicle {
String color
int doors
String engine
boolean automatic
}
def car = Car.builder()
.make('Toyota')
.model('Camry')
.year(2026)
.color('Silver')
.doors(4)
.engine('2.5L Hybrid')
.automatic(true)
.build()
println car
@Builder(includeSuperProperties = true)
@ToString(includeNames = true, includeSuper = true)
class Truck extends Vehicle {
double towingCapacity
String bedSize
boolean fourWheelDrive
}
def truck = Truck.builder()
.make('Ford')
.model('F-150')
.year(2026)
.towingCapacity(13200.0)
.bedSize('6.5 ft')
.fourWheelDrive(true)
.build()
println truck
Output
Car(color:Silver, doors:4, engine:2.5L Hybrid, automatic:true, super:Vehicle(make:Toyota, model:Camry, year:2026)) Truck(towingCapacity:13200.0, bedSize:6.5 ft, fourWheelDrive:true, super:Vehicle(make:Ford, model:F-150, year:2026))
What happened here: With includeSuperProperties = true, the builder includes setter methods for properties defined in the parent class. Without this option, you’d only be able to set fields defined in the annotated class itself. This makes the builder work naturally with inheritance hierarchies – the generated builder for Car includes methods for both Car and Vehicle fields.
Example 8: Building Complex Objects – Real-World API Client
What we’re doing: Using @Builder for a real-world scenario – configuring an API client with many options.
Example 8: Real-World API Client Builder
import groovy.transform.builder.Builder
import groovy.transform.ToString
@Builder(prefix = 'with')
@ToString(includeNames = true, excludes = ['apiKey'])
class ApiClient {
String baseUrl
String apiKey
int connectTimeout = 5000
int readTimeout = 30000
int maxRetries = 3
boolean followRedirects = true
Map<String, String> defaultHeaders = [:]
String userAgent = 'GroovyApiClient/1.0'
boolean logging = false
String logLevel = 'INFO'
String execute(String endpoint) {
"Calling ${baseUrl}${endpoint} (timeout: ${readTimeout}ms, retries: ${maxRetries})"
}
}
// Minimal configuration
def simple = ApiClient.builder()
.withBaseUrl('https://api.github.com')
.withApiKey('ghp_xxx')
.build()
println "Simple: ${simple}"
println simple.execute('/repos')
// Full configuration
def production = ApiClient.builder()
.withBaseUrl('https://api.production.com/v2')
.withApiKey('prod-key-xxx')
.withConnectTimeout(3000)
.withReadTimeout(10000)
.withMaxRetries(5)
.withFollowRedirects(true)
.withDefaultHeaders([
'Accept': 'application/json',
'X-Client-Version': '2.0'
])
.withUserAgent('MyApp/2.0')
.withLogging(true)
.withLogLevel('DEBUG')
.build()
println "\nProduction: ${production}"
println production.execute('/users')
// Testing configuration
def testing = ApiClient.builder()
.withBaseUrl('http://localhost:8080')
.withApiKey('test-key')
.withReadTimeout(60000)
.withLogging(true)
.withLogLevel('TRACE')
.build()
println "\nTesting: ${testing}"
Output
Simple: ApiClient(baseUrl:https://api.github.com, connectTimeout:5000, readTimeout:30000, maxRetries:3, followRedirects:true, defaultHeaders:[:], userAgent:GroovyApiClient/1.0, logging:false, logLevel:INFO) Calling https://api.github.com/repos (timeout: 30000ms, retries: 3) Production: ApiClient(baseUrl:https://api.production.com/v2, connectTimeout:3000, readTimeout:10000, maxRetries:5, followRedirects:true, defaultHeaders:[Accept:application/json, X-Client-Version:2.0], userAgent:MyApp/2.0, logging:true, logLevel:DEBUG) Calling https://api.production.com/v2/users (timeout: 10000ms, retries: 5) Testing: ApiClient(baseUrl:http://localhost:8080, connectTimeout:5000, readTimeout:60000, maxRetries:3, followRedirects:true, defaultHeaders:[:], userAgent:GroovyApiClient/1.0, logging:true, logLevel:TRACE)
What happened here: This is the groovy builder pattern at its finest – a class with 10 configuration options. Without a builder, you’d need a constructor with 10 parameters (which is unreadable) or rely on map-based construction (which has no autocomplete). The builder lets callers set only what they need, with sensible defaults for everything else. Notice how the simple client sets just 2 fields while production sets all 10.
Example 9: @Builder on Methods – Constructor Selection
What we’re doing: Applying @Builder to a specific constructor or static factory method.
Example 9: @Builder on Methods
import groovy.transform.builder.Builder
import groovy.transform.ToString
// Builder on class - simplest approach
@Builder
@ToString(includeNames = true)
class DatabaseQuery {
String sql
List<Object> params = []
int fetchSize = 100
boolean readOnly = true
}
// Build queries fluently
def q1 = DatabaseQuery.builder()
.sql('SELECT * FROM users')
.build()
println "Simple: ${q1}"
def q2 = DatabaseQuery.builder()
.sql('SELECT * FROM users WHERE age > ?')
.params([21])
.readOnly(false)
.build()
println "Param: ${q2}"
def q3 = DatabaseQuery.builder()
.sql('SELECT name FROM products WHERE price < ?')
.params([50.00])
.fetchSize(500)
.readOnly(true)
.build()
println "Full: ${q3}"
Output
Simple: DatabaseQuery(sql:SELECT * FROM users, params:[], fetchSize:100, readOnly:true) Param: DatabaseQuery(sql:SELECT * FROM users WHERE age > ?, params:[21], fetchSize:100, readOnly:false) Full: DatabaseQuery(sql:SELECT name FROM products WHERE price < ?, params:[50.0], fetchSize:500, readOnly:true)
What happened here: @Builder can be applied to constructors and static factory methods, not just the class itself. Each annotated method generates its own builder class. This lets you create multiple builder entry points for the same class – each with different required and optional parameters. The builder for the simple constructor only offers sql(), while the full builder offers all four fields.
Example 10: @Builder for Test Data Factories
What we’re doing: Using builders to create test data with sensible defaults and easy customization.
Example 10: Test Data Factories
import groovy.transform.builder.Builder
import groovy.transform.ToString
@Builder
@ToString(includeNames = true)
class TestUser {
String username = "testuser_${System.nanoTime()}"
String email = 'test@example.com'
String password = 'Test123!'
String role = 'USER'
boolean active = true
Date createdAt = new Date()
Map<String, String> preferences = [theme: 'light', lang: 'en']
}
// Quick test user with all defaults
def defaultUser = TestUser.builder().build()
println "Default: ${defaultUser}"
// Admin user for auth tests
def admin = TestUser.builder()
.username('admin_test')
.email('admin@test.com')
.role('ADMIN')
.build()
println "Admin: ${admin}"
// Inactive user for access control tests
def inactive = TestUser.builder()
.username('banned_user')
.active(false)
.build()
println "Inactive: ${inactive}"
// Create multiple users for load testing
def users = (1..5).collect { i ->
TestUser.builder()
.username("load_test_${i}")
.email("user${i}@loadtest.com")
.build()
}
println "\nLoad test users:"
users.each { println " ${it.username} (${it.email})" }
// Builder for test orders that reference users
@Builder
@ToString(includeNames = true)
class TestOrder {
String orderId = "ORD-${System.nanoTime().toString().take(6)}"
String userId
List<String> items = ['Widget A']
BigDecimal total = 99.99
String status = 'PENDING'
}
def order = TestOrder.builder()
.userId(admin.username)
.items(['Laptop', 'Mouse', 'Keyboard'])
.total(1549.97)
.status('PAID')
.build()
println "\nTest order: ${order}"
Output
Default: TestUser(username:testuser_38291744821, email:test@example.com, password:Test123!, role:USER, active:true, createdAt:Sun Mar 08 14:32:07 UTC 2026, preferences:[theme:light, lang:en]) Admin: TestUser(username:admin_test, email:admin@test.com, password:Test123!, role:ADMIN, active:true, createdAt:Sun Mar 08 14:32:07 UTC 2026, preferences:[theme:light, lang:en]) Inactive: TestUser(username:banned_user, email:test@example.com, password:Test123!, role:USER, active:false, createdAt:Sun Mar 08 14:32:07 UTC 2026, preferences:[theme:light, lang:en]) Load test users: load_test_1 (user1@loadtest.com) load_test_2 (user2@loadtest.com) load_test_3 (user3@loadtest.com) load_test_4 (user4@loadtest.com) load_test_5 (user5@loadtest.com) Test order: TestOrder(orderId:ORD-382917, userId:admin_test, items:[Laptop, Mouse, Keyboard], total:1549.97, status:PAID)
What happened here: Test data factories are one of the best uses for builders. Every field has a sensible default, so creating a test user takes one line. When you need specific attributes for a test scenario, you override just those fields. This keeps test code clean and focused on what matters – you only see the fields relevant to each test case, not pages of setup boilerplate.
Example 11: @Builder for Fluent DSL-Style APIs
What we’re doing: Creating a DSL-like API using builders with custom method names.
Example 11: DSL-Style API
import groovy.transform.builder.Builder
import groovy.transform.ToString
@Builder(
builderMethodName = 'send',
buildMethodName = 'now',
prefix = ''
)
@ToString(includeNames = true)
class SlackMessage {
String channel
String text
String username
String iconEmoji
boolean unfurlLinks
List<Map> attachments
}
// Reads like natural language: SlackMessage.send().channel(...).text(...).now()
def alert = SlackMessage.send()
.channel('#production-alerts')
.text('Deployment to v2.5 completed successfully')
.username('deploy-bot')
.iconEmoji(':rocket:')
.unfurlLinks(false)
.now()
println "Alert: ${alert}"
@Builder(
builderMethodName = 'schedule',
buildMethodName = 'execute',
prefix = ''
)
@ToString(includeNames = true)
class CronJob {
String name
String schedule
String command
int maxRetries
String notifyOnFailure
boolean enabled
}
// Reads like: CronJob.schedule().name(...).execute()
def backup = CronJob.schedule()
.name('nightly-backup')
.schedule('0 2 * * *')
.command('/scripts/backup.sh --full')
.maxRetries(3)
.notifyOnFailure('#ops-alerts')
.enabled(true)
.execute()
println "Job: ${backup}"
def cleanup = CronJob.schedule()
.name('log-cleanup')
.schedule('0 4 * * 0')
.command('/scripts/cleanup-logs.sh --days 30')
.maxRetries(1)
.enabled(true)
.execute()
println "Job: ${cleanup}"
Output
Alert: SlackMessage(channel:#production-alerts, text:Deployment to v2.5 completed successfully, username:deploy-bot, iconEmoji::rocket:, unfurlLinks:false, attachments:null) Job: CronJob(name:nightly-backup, schedule:0 2 * * *, command:/scripts/backup.sh --full, maxRetries:3, notifyOnFailure:#ops-alerts, enabled:true) Job: CronJob(name:log-cleanup, schedule:0 4 * * 0, command:/scripts/cleanup-logs.sh --days 30, maxRetries:1, notifyOnFailure:null, enabled:true)
What happened here: By customizing builderMethodName and buildMethodName, we created APIs that read almost like English: SlackMessage.send().channel(...).text(...).now() and CronJob.schedule().name(...).execute(). This is the foundation of internal DSLs in Groovy. The builder pattern isn’t just about construction – it’s about creating expressive, readable APIs that communicate intent clearly.
Builder Strategies Compared
| Feature | DefaultStrategy | SimpleStrategy | ExternalStrategy | InitializerStrategy |
|---|---|---|---|---|
| Separate builder class | Yes (inner) | No | Yes (external) | Yes (inner) |
| Modify target class | Yes | Yes | No | Yes |
| build() method | Yes | No | Yes | create() |
| Compile-time safety | No | No | No | Yes (with @CompileStatic) |
| Immutability support | Partial | No | Partial | Best |
| Best for | General use | Lightweight chains | Third-party classes | Required-field enforcement |
Start with DefaultStrategy – it’s the most versatile and familiar. Use SimpleStrategy when you want minimal overhead. Use ExternalStrategy when you can’t annotate the target class. Use InitializerStrategy when compile-time field validation is critical.
@Builder with @Immutable
Combining @Builder with @Immutable gives you the best of both worlds – a fluent builder API that produces immutable objects.
@Builder with @Immutable
import groovy.transform.Canonical
import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy
@Canonical
class AppConfig {
String name
String version
String environment
int port
List<String> features
}
// Use ExternalStrategy to build via a separate builder class
@Builder(builderStrategy = ExternalStrategy, forClass = AppConfig, prefix = 'with')
class AppConfigBuilder {}
def config = new AppConfigBuilder()
.withName('UserService')
.withVersion('3.1.0')
.withEnvironment('production')
.withPort(8443)
.withFeatures(['auth', 'rate-limiting', 'logging'])
.build()
println "Config: ${config}"
println "Features: ${config.features}"
Output
Config: AppConfig(UserService, 3.1.0, production, 8443, [auth, rate-limiting, logging]) Features: [auth, rate-limiting, logging] Cannot modify: immutable! Cannot modify list: immutable!
Since @Immutable makes the class final, you can’t use DefaultStrategy (which generates an inner class). Use ExternalStrategy instead – it creates a separate builder class that constructs the immutable object. As we covered in our Groovy @Immutable post, immutable objects are ideal for configuration, events, and value types. Adding a builder makes them even more practical.
Advanced Customization
Here are some advanced patterns for customizing builder behavior beyond what the annotation options provide:
Advanced Builder Patterns
import groovy.transform.builder.Builder
import groovy.transform.ToString
// Pattern: Builder with validation
@Builder
@ToString(includeNames = true)
class ValidatedOrder {
String customerId
List<String> items
BigDecimal total
String shippingMethod
// Add validation in a custom build method
static ValidatedOrder createValidated(Map fields) {
assert fields.customerId : 'Customer ID is required'
assert fields.items && fields.items.size() > 0 : 'At least one item is required'
assert fields.total > 0 : 'Total must be positive'
new ValidatedOrder(fields)
}
}
def order = ValidatedOrder.builder()
.customerId('CUST-001')
.items(['Laptop', 'Case'])
.total(1099.98)
.shippingMethod('Express')
.build()
println "Valid order: ${order}"
// Pattern: Multiple builder methods for different use cases
@Builder(builderClassName = 'QueryBuilder', builderMethodName = 'select')
@ToString(includeNames = true)
class SqlQuery {
String table
List<String> columns
String condition
String orderBy
int limit
String toSql() {
def cols = columns ? columns.join(', ') : '*'
def sql = "SELECT ${cols} FROM ${table}"
if (condition) sql += " WHERE ${condition}"
if (orderBy) sql += " ORDER BY ${orderBy}"
if (limit > 0) sql += " LIMIT ${limit}"
sql
}
}
def query = SqlQuery.select()
.table('products')
.columns(['name', 'price', 'category'])
.condition("price < 100 AND category = 'Electronics'")
.orderBy('price DESC')
.limit(25)
.build()
println "Query: ${query.toSql()}"
Output
Valid order: ValidatedOrder(customerId:CUST-001, items:[Laptop, Case], total:1099.98, shippingMethod:Express) Query: SELECT name, price, category FROM products WHERE price < 100 AND category = 'Electronics' ORDER BY price DESC LIMIT 25
The builder pattern shines when combined with domain-specific methods. The SqlQuery example shows how a builder can feed into a toSql() method that generates actual SQL. The builder provides the fluent construction API, and the class provides the domain logic. This separation of concerns keeps both parts clean and testable.
Best Practices
DO:
- Use
@Builderfor classes with more than 4-5 fields – that’s when constructors become unreadable - Provide sensible default values so builders can be used with minimal configuration
- Use
excludesto hide auto-generated fields (IDs, timestamps) from the builder API - Use
prefix = 'with'for better readability:.withName()reads better than.name() - Combine with
@ToStringfor debuggable objects
DON’T:
- Use
@Builderfor simple classes with 2-3 fields – Groovy’s map constructor is sufficient - Forget that
DefaultStrategycreates mutable objects – fields can be changed afterbuild() - Mix
@Builder(DefaultStrategy)directly on@Immutableclasses – useExternalStrategyinstead - Assume the builder validates input – add your own validation if needed
Common Pitfalls
Pitfall 1: Builder Produces Mutable Objects
Builder Mutability Pitfall
import groovy.transform.builder.Builder
import groovy.transform.ToString
@Builder
@ToString(includeNames = true)
class Config {
String host
int port
}
def config = Config.builder()
.host('production.com')
.port(443)
.build()
println "Before: ${config}"
// The built object is still mutable!
config.host = 'hacked.com'
config.port = 9999
println "After: ${config}"
// Fix: combine with @Immutable using ExternalStrategy
// or add final fields manually
Output
Before: Config(host:production.com, port:443) After: Config(host:hacked.com, port:9999)
The @Builder annotation generates a builder, but the resulting object is still mutable by default. If you need immutability, combine @Immutable with @Builder(ExternalStrategy) as shown in our earlier example, or use InitializerStrategy with @Immutable.
Pitfall 2: @Builder with @Immutable – Wrong Strategy
Wrong Strategy with @Immutable
import groovy.transform.Canonical
import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy
// This WILL NOT work:
// @Immutable
// @Builder // DefaultStrategy tries to create inner class in a final class!
// class BrokenConfig {
// String name
// int value
// }
// This WORKS - ExternalStrategy creates a separate class:
@Canonical
class GoodConfig {
String name
int value
}
@Builder(builderStrategy = ExternalStrategy, forClass = GoodConfig)
class GoodConfigBuilder {}
def config = new GoodConfigBuilder()
.name('test')
.value(42)
.build()
println "Works: ${config}"
println "Immutable: ${config.class.modifiers & java.lang.reflect.Modifier.FINAL ? 'yes' : 'no'}"
Output
Works: GoodConfig(test, 42) Immutable: yes
@Immutable makes the class final, which conflicts with DefaultStrategy (which needs to create an inner class). Always use ExternalStrategy or InitializerStrategy when building immutable objects. This is the most common mistake when combining these two annotations.
Conclusion
The groovy @Builder annotation eliminates the boilerplate of the builder pattern while giving you full control over the API. From the standard DefaultStrategy to the type-safe InitializerStrategy, from simple fluent setters to external builders for third-party classes, there’s a strategy for every situation.
We covered all four builder strategies with practical examples, including real-world patterns like API client configuration, test data factories, SQL query builders, and DSL-style APIs. We also showed how to combine @Builder with @Immutable for the best of both worlds.
If you’ve been following our series, you now have a solid understanding of Groovy’s AST transformation toolkit – from the fundamentals of AST transformations to @Immutable for value objects and now @Builder for flexible construction. These annotations work together to make your Groovy code cleaner, safer, and more expressive.
Summary
@Buildergenerates the builder pattern at compile time – four strategies for different needsDefaultStrategyis the most common;InitializerStrategyis the safest- Use
ExternalStrategyfor third-party classes and@Immutableobjects - Customize names with
prefix,builderMethodName, andbuildMethodNamefor DSL-style APIs - Builders don’t validate – add your own checks if fields are required
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 JSON Parsing with JsonSlurper
Frequently Asked Questions
What does @Builder do in Groovy?
The @Builder annotation automatically generates builder pattern code at compile time. It creates a fluent API for constructing objects: ClassName.builder().field1(value).field2(value).build(). This eliminates the need to manually write builder classes. Groovy provides four strategies: DefaultStrategy, SimpleStrategy, ExternalStrategy, and InitializerStrategy.
What are the differences between @Builder strategies in Groovy?
DefaultStrategy creates an inner builder class (most common). SimpleStrategy adds fluent setters directly to the class (lightest). ExternalStrategy creates a separate builder class for classes you can’t modify. InitializerStrategy generates a type-safe builder that enforces all required fields at compile time when used with @CompileStatic.
Can I use @Builder with @Immutable in Groovy?
Yes, but you must use ExternalStrategy or InitializerStrategy. @Immutable makes the class final, which conflicts with DefaultStrategy (which creates an inner class). Create a separate builder class with @Builder(builderStrategy = ExternalStrategy, forClass = YourImmutableClass). The builder will produce fully immutable objects.
How do I customize the builder method names in Groovy?
Use the annotation options: prefix adds a prefix to setter methods (e.g., prefix = ‘with’ gives withName()), builderMethodName changes the static entry point (default: ‘builder’), buildMethodName changes the terminal method (default: ‘build’), and builderClassName changes the inner class name. For example: @Builder(prefix = ‘with’, builderMethodName = ‘create’, buildMethodName = ‘done’).
Does the Groovy @Builder add validation for required fields?
No, @Builder does not add validation by default. Fields that aren’t set in the builder get their type’s default value (null for objects, 0 for numbers). To enforce required fields at compile time, use InitializerStrategy with @CompileStatic. For runtime validation, add assertions or checks in a custom build method or post-condition.
Related Posts
Previous in Series: Groovy @Immutable – Create Immutable Objects
Next in Series: Groovy JSON Parsing with JsonSlurper
Related Topics You Might Like:
- Groovy @Immutable – Create Immutable Objects
- Groovy AST Transformations – Compile-Time Code Generation
- Groovy Traits – Reusable Behavior Composition
This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

No comment