Builder Pattern #

Imagine a function NewHTTPClient(timeout, maxRetries, baseURL, authToken, userAgent, keepAlive, maxIdleConns, tlsSkipVerify, proxyURL, rateLimitRPS) with ten positional parameters. The call site looks like NewHTTPClient(30, 3, "https://api.example.com", "Bearer xyz", "MyApp/1.0", true, 10, false, "", 100) — without reading the function definition, nobody knows the fourth parameter is authToken or userAgent, and nobody can catch the two trailing ints being swapped. The Builder Pattern eliminates this problem completely: the object is constructed step by step, every step has a clear name, only relevant fields need to be set, and validation runs right before the object is handed to the caller.

What Is the Builder Pattern? #

The Builder Pattern is a creational design pattern that separates the object construction process from its final representation. Instead of passing every value through one constructor at once, you build the object gradually — setting one field at a time through a series of expressive methods — then call Build() to get a fully validated final object.

Three properties distinguish a Builder from an ordinary setter:

  • Immutability after Build() — the final object cannot be changed once handed over; the builder is the only way to construct it
  • Centralized validation — all invariants and business rules are validated in one place, right before the object is created
  • Fluent API — method chaining makes construction code read almost like a sentence
flowchart LR
    subgraph Without Builder
        C["NewUser(\\n  name,\\n  email,\\n  age,\\n  phone,\\n  address,\\n  role,\\n  isActive\\n)"]
    end

    subgraph With Builder
        direction TB
        B1["NewUserBuilder()"]
        B2[".SetName(name)"]
        B3[".SetEmail(email)"]
        B4[".SetRole(role)"]
        B5[".Build()"]
        B1 --> B2 --> B3 --> B4 --> B5
    end

    C -->|"is the 3rd param age or phone?"| Q["❓ Error-prone"]
    B5 -->|"every step has a name"| A["✓ Clear and safe"]

The Problem It Solves #

The Builder Pattern is not there for aesthetics. Concrete problems drive it.

Problem 1: Telescoping Constructors #

The more optional fields there are, the more constructor variants you need with the conventional approach:

// ANTI-PATTERN: telescoping constructor — every field combination needs its own function
func NewUser(name, email string) *User { ... }
func NewUserWithAge(name, email string, age int) *User { ... }
func NewUserWithPhone(name, email, phone string) *User { ... }
func NewUserWithRole(name, email, role string) *User { ... }
func NewUserWithAgeAndPhone(name, email string, age int, phone string) *User { ... }
// ... combinations keep growing exponentially

// CORRECT: one builder handles every combination
user, err := NewUserBuilder().
    SetName("Budi").
    SetEmail("[email protected]").
    SetAge(28).
    SetPhone("+6281234567890").
    SetRole("admin").
    Build()

Problem 2: Same-Typed Parameters That Are Easy to Swap #

The compiler will not help if you swap two adjacent strings or two ints:

// ANTI-PATTERN: two adjacent strings — the compiler does not catch this
NewServer("localhost", "8080", "/api", "DEBUG")
//         ^^host^^    ^port^  ^base^  ^level^

// Easy to swap by mistake:
NewServer("8080", "localhost", "/api", "DEBUG") // runtime error, not a compile error

// CORRECT: every value has an explicit name
server, err := NewServerBuilder().
    SetHost("localhost").
    SetPort(8080).       // port as int — type-safe
    SetBasePath("/api").
    SetLogLevel("DEBUG").
    Build()

Problem 3: Validation Scattered Around #

Without a builder, validation usually happens in many places: in constructors, in setters, in the service that uses the object. The result: the same rule can be applied inconsistently in every spot.

// ANTI-PATTERN: validation scattered across many places
func CreateUser(name, email string, age int) (*User, error) {
    if name == "" { return nil, errors.New("name required") } // validation here
    user := &User{Name: name, Email: email, Age: age}
    // but email is not validated here — depends on who remembers
    return user, nil
}

// In the service, more validation:
func (s *UserService) Register(name, email string, age int) error {
    if !isValidEmail(email) { return errors.New("invalid email") } // duplicated validation
    user, err := CreateUser(name, email, age)
    // ...
}

// CORRECT: all validation in Build() — one place, impossible to miss
func (b *UserBuilder) Build() (*User, error) {
    if b.name == "" {
        return nil, errors.New("name is required")
    }
    if !isValidEmail(b.email) {
        return nil, fmt.Errorf("invalid email: %s", b.email)
    }
    if b.age < 0 || b.age > 150 {
        return nil, fmt.Errorf("invalid age: %d", b.age)
    }
    return &User{
        name:  b.name,
        email: b.email,
        age:   b.age,
    }, nil
}

Builder Components in Go #

In languages like Java, the Builder Pattern involves a Builder class separate from the Product class, sometimes with a Director as a third layer. In Go, a more pragmatic and idiomatic approach is used — no mandatory Builder interface, just a builder struct with method chaining.

flowchart TD
    subgraph "Go Builder Components"
        direction TB
        P["Product (struct with unexported fields)\\nCan only be created through the builder"]
        B["Builder (separate struct)\\nHolds temporary values"]
        M["Fluent Methods\\n.SetX() *Builder — returns self for chaining"]
        V["Build()\\nValidation + construction of the final Product"]
    end

    B -->|"method chaining"| M
    M --> V
    V -->|"valid"| P
    V -->|"invalid"| E["error"]

The four components:

ComponentRoleCharacteristics in Go
ProductThe final object producedUnexported fields, only created via the builder
Builder structAccumulates temporary valuesExported or unexported fields, mutable
Fluent methodsSetters that return *BuilderReturn *Builder for method chaining
Build() methodValidation + Product constructionReturns (*Product, error)

Full Implementation: HTTP Client Builder #

Let’s build something more realistic than a User — an HTTP client configurable with many optional parameters. This is a use case you meet often in production applications.

Product: Struct with Unexported Fields #

package httpclient

import (
    "crypto/tls"
    "net/http"
    "time"
)

// Client is the product — a fully configured, ready-to-use HTTP client.
// All fields are unexported: the only way to create a Client is through ClientBuilder.
type Client struct {
    baseURL        string
    httpClient     *http.Client
    defaultHeaders map[string]string
    maxRetries     int
    retryDelay     time.Duration
    rateLimitRPS   int
}

// Do sends an HTTP request using the configured settings.
func (c *Client) Do(req *http.Request) (*http.Response, error) {
    // Add default headers
    for key, value := range c.defaultHeaders {
        if req.Header.Get(key) == "" {
            req.Header.Set(key, value)
        }
    }
    return c.httpClient.Do(req)
}

// BaseURL returns the configured base URL.
func (c *Client) BaseURL() string { return c.baseURL }

// MaxRetries returns the configured maximum retry count.
func (c *Client) MaxRetries() int { return c.maxRetries }

Builder: Temporary Value Accumulator #

// ClientBuilder accumulates all configuration values before the Client is created.
type ClientBuilder struct {
    baseURL        string
    timeout        time.Duration
    maxRetries     int
    retryDelay     time.Duration
    rateLimitRPS   int
    defaultHeaders map[string]string
    tlsSkipVerify  bool
    maxIdleConns   int
    keepAlive      time.Duration
    proxyURL       string
}

// NewClientBuilder creates a builder with sensible default values.
// Defaults are set here, not on the Product.
func NewClientBuilder(baseURL string) *ClientBuilder {
    return &ClientBuilder{
        baseURL:      baseURL,
        timeout:      30 * time.Second,   // 30 seconds — safe for most cases
        maxRetries:   3,                  // retry up to 3 times
        retryDelay:   500 * time.Millisecond,
        maxIdleConns: 100,
        keepAlive:    90 * time.Second,
        defaultHeaders: map[string]string{
            "Content-Type": "application/json",
            "Accept":       "application/json",
        },
    }
}

Fluent Setter Methods #

Each method returns *ClientBuilder — that is what enables method chaining.

// WithTimeout sets the timeout for every request.
func (b *ClientBuilder) WithTimeout(d time.Duration) *ClientBuilder {
    b.timeout = d
    return b
}

// WithMaxRetries sets the maximum number of retry attempts when a request fails.
func (b *ClientBuilder) WithMaxRetries(n int) *ClientBuilder {
    b.maxRetries = n
    return b
}

// WithRetryDelay sets the pause between retry attempts.
func (b *ClientBuilder) WithRetryDelay(d time.Duration) *ClientBuilder {
    b.retryDelay = d
    return b
}

// WithRateLimit sets the request-per-second limit.
// A value of 0 means no rate limiting.
func (b *ClientBuilder) WithRateLimit(rps int) *ClientBuilder {
    b.rateLimitRPS = rps
    return b
}

// WithHeader adds a default header included in every request.
func (b *ClientBuilder) WithHeader(key, value string) *ClientBuilder {
    b.defaultHeaders[key] = value
    return b
}

// WithAuthToken sets the Authorization header with a Bearer token.
func (b *ClientBuilder) WithAuthToken(token string) *ClientBuilder {
    b.defaultHeaders["Authorization"] = "Bearer " + token
    return b
}

// WithTLSSkipVerify disables TLS certificate verification.
// DO NOT use in production — development/testing only.
func (b *ClientBuilder) WithTLSSkipVerify(skip bool) *ClientBuilder {
    b.tlsSkipVerify = skip
    return b
}

// WithMaxIdleConns sets the maximum number of idle connections in the pool.
func (b *ClientBuilder) WithMaxIdleConns(n int) *ClientBuilder {
    b.maxIdleConns = n
    return b
}

// WithProxy sets the proxy URL to be used.
func (b *ClientBuilder) WithProxy(proxyURL string) *ClientBuilder {
    b.proxyURL = proxyURL
    return b
}

Build Method with Comprehensive Validation #

Build() is where all validation runs before the object is handed to the caller.

// Build validates all the configuration and creates a ready-to-use Client.
// Returns an error if any configuration is invalid.
func (b *ClientBuilder) Build() (*Client, error) {
    // Validate required fields
    if b.baseURL == "" {
        return nil, errors.New("baseURL is required")
    }
    if _, err := url.ParseRequestURI(b.baseURL); err != nil {
        return nil, fmt.Errorf("invalid baseURL %q: %w", b.baseURL, err)
    }

    // Validate numeric values
    if b.timeout <= 0 {
        return nil, fmt.Errorf("timeout must be positive, got %v", b.timeout)
    }
    if b.maxRetries < 0 {
        return nil, fmt.Errorf("maxRetries must be non-negative, got %d", b.maxRetries)
    }
    if b.rateLimitRPS < 0 {
        return nil, fmt.Errorf("rateLimitRPS must be non-negative, got %d", b.rateLimitRPS)
    }
    if b.maxIdleConns <= 0 {
        return nil, fmt.Errorf("maxIdleConns must be positive, got %d", b.maxIdleConns)
    }

    // Construct the http.Transport
    transport := &http.Transport{
        MaxIdleConns:    b.maxIdleConns,
        IdleConnTimeout: b.keepAlive,
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: b.tlsSkipVerify, //nolint:gosec
        },
    }

    // Set the proxy if present
    if b.proxyURL != "" {
        proxyURL, err := url.Parse(b.proxyURL)
        if err != nil {
            return nil, fmt.Errorf("invalid proxyURL %q: %w", b.proxyURL, err)
        }
        transport.Proxy = http.ProxyURL(proxyURL)
    }

    // Copy defaultHeaders so they cannot be mutated from outside after Build()
    headers := make(map[string]string, len(b.defaultHeaders))
    for k, v := range b.defaultHeaders {
        headers[k] = v
    }

    return &Client{
        baseURL: b.baseURL,
        httpClient: &http.Client{
            Timeout:   b.timeout,
            Transport: transport,
        },
        defaultHeaders: headers,
        maxRetries:     b.maxRetries,
        retryDelay:     b.retryDelay,
        rateLimitRPS:   b.rateLimitRPS,
    }, nil
}

Usage: Clean and Expressive #

// Client for a payment gateway — tight timeout, auth token, rate limited
paymentClient, err := httpclient.NewClientBuilder("https://api.payment-gateway.com").
    WithTimeout(10 * time.Second).
    WithMaxRetries(2).
    WithRetryDelay(1 * time.Second).
    WithAuthToken(cfg.PaymentAPIKey).
    WithRateLimit(50).
    WithHeader("X-Merchant-ID", cfg.MerchantID).
    Build()
if err != nil {
    return fmt.Errorf("failed to build payment client: %w", err)
}

// Client for an internal service — loose timeout, no auth, no retry
internalClient, err := httpclient.NewClientBuilder("http://user-service:8080").
    WithTimeout(5 * time.Second).
    WithMaxRetries(0).
    WithMaxIdleConns(200).
    Build()
if err != nil {
    return fmt.Errorf("failed to build internal client: %w", err)
}

// Client for development — skip TLS, verbose
devClient, err := httpclient.NewClientBuilder("https://localhost:9443").
    WithTLSSkipVerify(true).
    WithHeader("X-Debug", "true").
    Build()

The Director Pattern: Configuration Presets #

In the classic GoF implementation, there is a Director component that orchestrates the object creation sequence and provides commonly used configuration presets. In Go, this can be implemented as helper functions that use the builder internally.

// Director provides common configuration presets.
// This avoids duplicating the same configuration in many places.
type ClientDirector struct {
    config AppConfig
}

func NewClientDirector(cfg AppConfig) *ClientDirector {
    return &ClientDirector{config: cfg}
}

// BuildPaymentClient creates a client configured for the payment gateway.
func (d *ClientDirector) BuildPaymentClient() (*Client, error) {
    return NewClientBuilder(d.config.PaymentGatewayURL).
        WithTimeout(10 * time.Second).
        WithMaxRetries(2).
        WithRetryDelay(1 * time.Second).
        WithAuthToken(d.config.PaymentAPIKey).
        WithRateLimit(50).
        WithHeader("X-Merchant-ID", d.config.MerchantID).
        Build()
}

// BuildInternalServiceClient creates a client for internal inter-service communication.
func (d *ClientDirector) BuildInternalServiceClient(serviceURL string) (*Client, error) {
    return NewClientBuilder(serviceURL).
        WithTimeout(5 * time.Second).
        WithMaxRetries(3).
        WithMaxIdleConns(200).
        WithHeader("X-Service-Name", d.config.ServiceName).
        Build()
}

// BuildExternalAPIClient creates a client for external APIs with rate limiting.
func (d *ClientDirector) BuildExternalAPIClient(apiURL, apiKey string) (*Client, error) {
    return NewClientBuilder(apiURL).
        WithTimeout(30 * time.Second).
        WithMaxRetries(1).
        WithAuthToken(apiKey).
        WithRateLimit(10). // external APIs usually have strict rate limits
        Build()
}

With a Director, usage becomes very concise:

director := NewClientDirector(cfg)

paymentClient, err := director.BuildPaymentClient()
userServiceClient, err := director.BuildInternalServiceClient("http://user-service:8080")
analyticsClient, err := director.BuildExternalAPIClient("https://analytics.example.com", apiKey)

Builder vs Functional Options Pattern #

In Go, there is another popular idiom for the same problem: the Functional Options Pattern. Both are valid — the choice depends on the characteristics of the object being built.

// Functional Options — an alternative for simpler cases
type Option func(*Client)

func WithTimeout(d time.Duration) Option {
    return func(c *Client) {
        c.timeout = d
    }
}

func WithAuthToken(token string) Option {
    return func(c *Client) {
        c.defaultHeaders["Authorization"] = "Bearer " + token
    }
}

func NewClient(baseURL string, opts ...Option) (*Client, error) {
    c := &Client{
        baseURL: baseURL,
        timeout: 30 * time.Second, // default
    }
    for _, opt := range opts {
        opt(c)
    }
    return c, validate(c)
}

// Usage
client, err := NewClient("https://api.example.com",
    WithTimeout(10*time.Second),
    WithAuthToken("secret"),
)

Comparing the two:

AspectBuilder PatternFunctional Options
Ordered steps matterEasy to implementHard — every option is independent
Lots of internal stateVery well suitedPossible, but less structured
Cross-field validationCentralized in Build()Must be run separately
Ease of extensionAdd a new methodAdd a new option function
Product immutabilityEasy — unexported fieldsEasy
Testing the builder itselfEasy — test each methodHarder — options are closures
Best forComplex objects with lots of stateLightweight config with independent options
CHOOSE the Builder Pattern if:
  ✓ Many fields depend on each other and must be validated together
  ✓ The construction process has an important order
  ✓ A Director is needed to provide several configuration presets
  ✓ The object is very complex with more than 7-10 parameters

CHOOSE Functional Options if:
  ✓ Options are independent of each other
  ✓ The configuration is relatively simple (3-7 options)
  ✓ The order in which values are set does not matter
  ✓ You want a more minimal API

Testing a Builder #

The Builder Pattern makes testing easier because there is a single validation point. You can test every scenario without having to create a fully formed object.

func TestClientBuilder_DefaultValues(t *testing.T) {
    client, err := NewClientBuilder("https://api.example.com").Build()

    if err != nil {
        t.Fatalf("expected no error with valid config, got: %v", err)
    }
    // Verify default values were applied
    if client.MaxRetries() != 3 {
        t.Errorf("expected default maxRetries=3, got %d", client.MaxRetries())
    }
    if client.BaseURL() != "https://api.example.com" {
        t.Errorf("expected baseURL to match input")
    }
}

func TestClientBuilder_ValidationErrors(t *testing.T) {
    tests := []struct {
        name        string
        setupFn     func(*ClientBuilder) *ClientBuilder
        expectedErr string
    }{
        {
            name:        "empty baseURL",
            setupFn:     func(b *ClientBuilder) *ClientBuilder { return b },
            expectedErr: "baseURL is required",
        },
        {
            name: "invalid baseURL",
            setupFn: func(b *ClientBuilder) *ClientBuilder {
                return NewClientBuilder("not-a-url")
            },
            expectedErr: "invalid baseURL",
        },
        {
            name: "negative timeout",
            setupFn: func(b *ClientBuilder) *ClientBuilder {
                return NewClientBuilder("https://api.example.com").
                    WithTimeout(-1 * time.Second)
            },
            expectedErr: "timeout must be positive",
        },
        {
            name: "negative maxRetries",
            setupFn: func(b *ClientBuilder) *ClientBuilder {
                return NewClientBuilder("https://api.example.com").
                    WithMaxRetries(-1)
            },
            expectedErr: "maxRetries must be non-negative",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            builder := &ClientBuilder{}
            _, err := tt.setupFn(builder).Build()

            if err == nil {
                t.Error("expected error, got nil")
                return
            }
            if !strings.Contains(err.Error(), tt.expectedErr) {
                t.Errorf("expected error containing %q, got %q", tt.expectedErr, err.Error())
            }
        })
    }
}

func TestClientBuilder_CustomHeaders(t *testing.T) {
    client, err := NewClientBuilder("https://api.example.com").
        WithAuthToken("secret-token").
        WithHeader("X-Custom", "value").
        Build()

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    // Verify headers were applied — using getters if available
    // or through a behavior test (send a request and check the received header)
    _ = client
}

A Builder Must Not Be Reused After Build()

Once Build() has been called, do not reuse the same builder to create a second object. The builder’s state may have been modified during construction, and sharing state between two objects is a recipe for bugs that are hard to trace. If you need a second object with similar configuration, create a new builder or implement a Clone() method on the builder.


Common Mistakes #

// ✗ Mistake 1: Builder without validation in Build()
func (b *UserBuilder) Build() *User {
    return &User{name: b.name, email: b.email} // no validation — invalid state can slip through
}

// ✓ Solution: always validate and return an error
func (b *UserBuilder) Build() (*User, error) {
    if b.name == "" {
        return nil, errors.New("name is required")
    }
    if !isValidEmail(b.email) {
        return nil, fmt.Errorf("invalid email: %s", b.email)
    }
    return &User{name: b.name, email: b.email}, nil
}


// ✗ Mistake 2: Putting business logic inside the builder
func (b *OrderBuilder) SetDiscount(code string) *OrderBuilder {
    // DON'T: query the database or call external services from the builder
    discount, _ := db.GetDiscount(code)
    b.discountAmount = discount.Amount
    b.discountCode = code
    return b
}

// ✓ Solution: the builder only accepts values already computed outside
func (b *OrderBuilder) WithDiscount(code string, amount int) *OrderBuilder {
    b.discountCode = code
    b.discountAmount = amount
    return b
}


// ✗ Mistake 3: Exposing Product fields — the builder becomes pointless
type User struct {
    Name  string // exported — anyone can set it directly without going through the builder
    Email string
    Age   int
}

// ✓ Solution: Product fields are always unexported
type User struct {
    name  string
    email string
    age   int
}

// Provide getters if values need to be read from outside
func (u *User) Name() string  { return u.name }
func (u *User) Email() string { return u.email }


// ✗ Mistake 4: Panicking when validation fails, instead of returning an error
func (b *ClientBuilder) Build() *Client {
    if b.baseURL == "" {
        panic("baseURL is required") // crashes the application — cannot be handled
    }
    return &Client{baseURL: b.baseURL}
}

// ✓ Solution: always return an error, let the caller decide how to handle it
func (b *ClientBuilder) Build() (*Client, error) {
    if b.baseURL == "" {
        return nil, errors.New("baseURL is required")
    }
    return &Client{baseURL: b.baseURL}, nil
}


// ✗ Mistake 5: Using a Builder for a very simple object
type Point struct{ x, y int }

type PointBuilder struct{ x, y int }
func (b *PointBuilder) SetX(x int) *PointBuilder { b.x = x; return b }
func (b *PointBuilder) SetY(y int) *PointBuilder { b.y = y; return b }
func (b *PointBuilder) Build() Point { return Point{x: b.x, y: b.y} }

// ✓ Solution: for simple objects, a plain constructor is enough
func NewPoint(x, y int) Point { return Point{x: x, y: y} }

Builder Review Checklist #

PRODUCT:
  □ All fields are unexported — no direct access from outside the package
  □ Getters are provided for fields that need to be read externally
  □ No setters on the Product — immutable after Build()

BUILDER:
  □ Sensible default values are set in the builder constructor, not on the Product
  □ Every fluent method returns *Builder for method chaining
  □ No business logic inside the builder — value accumulation only

BUILD METHOD:
  □ All required fields are validated
  □ All cross-field invariants are validated (e.g., maxRetries >= 0)
  □ Returns an error, not a panic
  □ Slices and maps are copied before being assigned to the Product — avoid shared mutable state

TESTING:
  □ Default values are tested explicitly
  □ Every error scenario is validated in a separate test
  □ Table-driven tests are used for many validation scenarios
  □ The builder is not reused across different test cases

DIRECTOR (if present):
  □ Every preset method is named after a use case, not a configuration
  □ The Director receives configuration via its constructor, not hardcoded
  □ The Director can be mocked for testing the components that use it

Summary #

  • Builder separates construction from representation — objects are built step by step through clearly named methods, not through confusing parameter order.
  • Three problems solved: telescoping constructors, same-typed parameters that are easy to swap, and validation scattered across many places.
  • Product fields must be unexported — this forces all code through the builder and makes the object truly immutable after Build().
  • Validation centralized in Build() — all invariants and business rules are validated in one place; return an error, don’t panic.
  • Default values live in the builder, not the Product — the builder owns sensible initial values; the Product only stores the final values.
  • A Director provides presets — for common configurations used in many places; it avoids duplicating the exact same builder chain.
  • Builder vs Functional Options: choose Builder when there is lots of interdependent state, complex cross-field validation, or an important step order; choose Functional Options for lightweight configuration with independent options.
  • Don’t use a Builder for simple objects — 1-3 fields do not need a builder; a plain constructor is more than enough.

← Previous: Abstract Factory   Next: Prototype →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact