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:
| Component | Role | Characteristics in Go |
|---|---|---|
| Product | The final object produced | Unexported fields, only created via the builder |
| Builder struct | Accumulates temporary values | Exported or unexported fields, mutable |
| Fluent methods | Setters that return *Builder | Return *Builder for method chaining |
Build() method | Validation + Product construction | Returns (*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:
| Aspect | Builder Pattern | Functional Options |
|---|---|---|
| Ordered steps matter | Easy to implement | Hard — every option is independent |
| Lots of internal state | Very well suited | Possible, but less structured |
| Cross-field validation | Centralized in Build() | Must be run separately |
| Ease of extension | Add a new method | Add a new option function |
| Product immutability | Easy — unexported fields | Easy |
| Testing the builder itself | Easy — test each method | Harder — options are closures |
| Best for | Complex objects with lots of state | Lightweight 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 aClone()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.