Groovy @Builder Annotation – Builder Pattern Made Easy with 10+ Examples

Groovy builder pattern with the @Builder annotation with 10+ examples. DefaultStrategy, SimpleStrategy, ExternalStrategy, InitializerStrategy explained.

“Separate the construction of a complex object from its representation.”

Design Patterns (1994), Builder intent

Last Updated: July 2026 | Tested on: Groovy 5.0.6, 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.

The diagram below shows a quick decision path for picking the right builder strategy, based on whether you own the class, need compile time required field checks, are dealing with a final or immutable type, and how lightweight you want the generated API to be:

No, third partyYesYesNoYesNoNo, lightest settersYesNeed a fluent builderCan you annotatethe target classExternalStrategyseparate builder classNeed compile timerequired field checksInitializerStrategybuilds via constructorTarget is finalor truly immutableWant a build methodand inner classSimpleStrategychained setters, no buildDefaultStrategybuilder() then build()Groovy @Builder: Choosing DefaultStrategy vs SimpleStrategy vs ExternalStrategy vs InitializerStrategy

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

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 and build() 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

OptionDefaultDescription
builderStrategyDefaultStrategyWhich 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
includesall fieldsFields to include in the builder
excludes[]Fields to exclude from the builder
includeSuperPropertiesfalseInclude parent class properties
allNamesfalseInclude 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 an explicit prefix, SimpleStrategy defaults to 'set'
// so the methods are setTable(), setColumns(), and so on
@Builder(builderStrategy = SimpleStrategy)
@ToString(includeNames = true)
class QueryParams {
    String table
    List<String> columns
    String whereClause
    String orderBy
    int limit
}

def query = new QueryParams()
    .setTable('users')
    .setColumns(['id', 'name', 'email'])
    .setWhereClause("active = true")
    .setOrderBy('name ASC')
    .setLimit(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. One thing that trips people up: SimpleStrategy’s default prefix is already 'set', so even without specifying a prefix the methods are setTable(), setColumns(), and so on (not bare table() or columns()). If you want unprefixed method names, pass prefix = '' explicitly. 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 - pass the fully populated initializer to the constructor
def config = new ServerConfig(ServerConfig.createInitializer()
    .host('api.example.com')
    .port(443)
    .protocol('https'))

println config

// With CompileStatic, missing fields cause a compile error:
// @CompileStatic
// void broken() {
//     def c = new ServerConfig(ServerConfig.createInitializer()
//         .host('test')
//         .port(80))
//         // .protocol() is missing - won't compile!
// }

// The same constructor form works dynamically too
def partial = new ServerConfig(ServerConfig.createInitializer()
    .host('localhost')
    .port(8080)
    .protocol('http'))

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. You start with createInitializer(), chain the field setters, then pass the populated initializer straight to the class constructor to get the finished object. That constructor only accepts an initializer whose type parameters show every field set, so with @CompileStatic a missing field is a compile error. 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:674d8a80, title:Understanding Groovy Builders, content:The builder pattern is..., author:Alice, tags:[groovy, patterns, tutorial], createdAt:Fri Jul 03 01:57:42 SGT 2026, updatedAt:Fri Jul 03 01:57:42 SGT 2026)
ID (auto): 674d8a80
Created (auto): 2026-07-03
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. The id and createdAt values come from the field initializers, so they change on every run (a fresh random UUID and the current date).

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:0, readTimeout:0, maxRetries:0, followRedirects:false, defaultHeaders:null, userAgent:null, logging:false, logLevel:null)
Calling https://api.github.com/repos (timeout: 0ms, retries: 0)

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:0, readTimeout:60000, maxRetries:0, followRedirects:false, defaultHeaders:null, userAgent:null, 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. One important detail shows up in the output: DefaultStrategy’s build() does not run your field initializers, so any field you leave unset comes back as its JVM zero value (0, false, null) rather than the default you declared.

That is why the simple and testing clients show connectTimeout:0 and userAgent:null even though the class declares defaults for them. If you need those defaults to apply, set the fields explicitly in the builder, or use a map constructor (new ApiClient(baseUrl: ...)) which does honor initializers.

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:null, fetchSize:0, readOnly:false)
Param: DatabaseQuery(sql:SELECT * FROM users WHERE age > ?, params:[21], fetchSize:0, readOnly:false)
Full: DatabaseQuery(sql:SELECT name FROM products WHERE price < ?, params:[50.00], fetchSize:500, readOnly:true)

What happened here: A single class-level @Builder gives you one fluent entry point with a method for every field, and you call only the ones you need. @Builder can also be placed on individual constructors or static factory methods when you want several distinct builder entry points on the same class. Watch the defaults again: the “Simple” query leaves params and fetchSize unset, so they come back as null and 0 rather than the [] and 100 declared on the fields. DefaultStrategy’s build() skips the field initializers, so set those fields explicitly if you rely on their defaults.

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:null, email:null, password:null, role:null, active:false, createdAt:null, preferences:null)
Admin: TestUser(username:admin_test, email:admin@test.com, password:null, role:ADMIN, active:false, createdAt:null, preferences:null)
Inactive: TestUser(username:banned_user, email:null, password:null, role:null, active:false, createdAt:null, preferences:null)

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:null, 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: you override just the attributes a test cares about and leave the rest alone, which keeps test code focused instead of drowning in setup boilerplate. But notice the “Default” user is all null and 0. The field initializers (the random username, the test@example.com email, the USER role) are declared on the class, yet DefaultStrategy’s build() ignores them, so an unset field falls back to its JVM zero value. If you want those defaults for factory objects, either set each field in the builder or construct with the map constructor (new TestUser()), which does run the initializers.

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.

Groovy Builder Pattern Strategies Compared

FeatureDefaultStrategySimpleStrategyExternalStrategyInitializerStrategy
Separate builder classYes (inner)NoYes (external)Yes (inner)
Modify target classYesYesNoYes
build() methodYesNoYescreate()
Compile-time safetyNoNoNoYes (with @CompileStatic)
Immutability supportPartialNoPartialBest
Best forGeneral useLightweight chainsThird-party classesRequired-field enforcement

Choosing a Groovy builder pattern strategy comes down to constraints. 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

You often want a fluent builder API in front of a well-constructed value object. The example below pairs ExternalStrategy with a @Canonical class so the separate builder can populate it through setters. One caveat worth knowing up front: ExternalStrategy builds by calling setters, so it cannot populate a true @Immutable class (its properties are read-only). For genuinely immutable objects, reach for InitializerStrategy, which builds through the constructor.

@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]

Since @Immutable makes the class final, you can’t use DefaultStrategy (which needs to generate an inner class). ExternalStrategy sidesteps the final problem by living in its own class, which is why it works here with @Canonical. If you need the built object to be truly immutable, use InitializerStrategy on the @Immutable class instead, since it constructs the object in one shot rather than through setters. 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 @Builder for 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 excludes to hide auto-generated fields (IDs, timestamps) from the builder API
  • Use prefix = 'with' for better readability: .withName() reads better than .name()
  • Combine with @ToString for debuggable objects

DON’T:

  • Use @Builder for simple classes with 2-3 fields – Groovy’s map constructor is sufficient
  • Forget that DefaultStrategy creates mutable objects – fields can be changed after build()
  • Mix @Builder(DefaultStrategy) directly on @Immutable classes – use ExternalStrategy instead
  • 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}"
// @Canonical is not final, so ExternalStrategy can populate it via setters
println "Final class: ${config.class.modifiers & java.lang.reflect.Modifier.FINAL ? 'yes' : 'no'}"

Output

Works: GoodConfig(test, 42)
Final class: no

@Immutable makes the class final, which conflicts with DefaultStrategy (which needs to create an inner class). The example above uses @Canonical, which is not final, so ExternalStrategy can populate it through setters (that is why Final class prints no). Because ExternalStrategy builds via setters, it cannot fill a genuinely @Immutable class whose properties are read-only; for that case use InitializerStrategy, which builds through the constructor. This is the most common mistake when combining these two annotations.

Conclusion

The groovy @Builder annotation eliminates the boilerplate of the Groovy 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

  • @Builder generates the builder pattern at compile time – four strategies for different needs
  • DefaultStrategy is the most common; InitializerStrategy is the safest
  • Use ExternalStrategy for third-party classes and @Immutable objects
  • Customize names with prefix, builderMethodName, and buildMethodName for 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 Groovy 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.

Interview Questions on the Groovy Builder Pattern

“These come from real screens. Practice answering before you read each answer.”

Q: A teammate builds a shared config object with @Builder, caches it, and weeks later a bug shows another service mutating it in place. He thought builders produced immutable objects. What do you tell him?

That the builder only changes how the object is constructed, not what it is. Once build() returns, you have a plain mutable POGO with public setters, exactly as if you had called the constructor. Fluent construction and immutability are orthogonal, and plenty of people conflate them because libraries like Lombok pair the two by convention. If the object must not change after construction, combine the builder with @Immutable using InitializerStrategy, or at least make properties final. A cached mutable config shared across threads is a race condition waiting for a stack trace.

Q: Your builder-generated test factory silently drops the fields inherited from a parent class: Employee.builder().name('Ana') compiles but name comes from Person and is not there. What happened?

By default @Builder only looks at the properties declared in the annotated class itself, so anything inherited from a superclass is invisible to the generated builder. The fix is one parameter: @Builder(includeSuperProperties = true), after which parent properties get their own fluent setters too. It fails quietly in dynamic Groovy because the missing method only surfaces when called, so in a test factory you may not notice until a field is unexpectedly null. Worth mentioning in review: includes and excludes give you precise control when the parent drags in properties you do not want.

Q: The team chose InitializerStrategy specifically so that forgetting a required field fails at compile time. In production, an object still came out with a null field. How is that possible?

InitializerStrategy’s safety net is woven from generics that only the static type checker inspects. If the calling code is compiled dynamically, without @CompileStatic or @TypeChecked, those phantom types are never checked and the call happily proceeds with fields unset. So the guarantee is conditional: the annotation on the class is not enough, the call sites must be statically compiled too. In a mixed codebase I would enforce that with a code review rule or a compiler configuration script. That subtlety, “the safety lives at the call site”, is exactly what the interviewer is fishing for.

Q: You need fluent construction for a class from a third-party JAR that you obviously cannot annotate. Walk me through your approach.

That is the textbook case for ExternalStrategy. You create a small class of your own, annotate it with @Builder(builderStrategy = ExternalStrategy, forClass = TheirClass), and Groovy generates a standalone builder that constructs the third-party object and populates it through its setters. The constraint to call out: because it works through setters, the target class needs a no-arg constructor and writable properties, so it cannot drive a constructor-only or immutable third-party type. In that case I would wrap the type in my own value class instead and build that.

Q: A reviewer asks why you put @Builder on a static factory method instead of on the class. When is that actually useful?

Annotating the class builds from all its properties, which is sometimes exactly wrong. Placing @Builder on a constructor or a static method scopes the builder to that parameter list, so you can expose a fluent API for one blessed way of creating the object, defaults applied, derived fields computed, while keeping other constructors out of the fluent surface. I use it when a class has several construction paths and only one should be public-facing, or when the factory method does normalization that field-by-field assignment would skip. It keeps the builder honest about invariants instead of just spraying setters.

Previous in Series: Groovy @Immutable – Create Immutable Objects

Next in Series: Groovy JSON Parsing with JsonSlurper

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 *