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.

“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.

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

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

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 @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}"
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

  • @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 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.

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 *