📚 Go Design Patterns 🔨Creational Pattern

What is Builder Pattern?

The Builder pattern is a creational design pattern that constructs complex objects step by step. It allows you to produce different types and representations of an object using the same construction code. Think of it like assembling a custom computer - you choose the CPU, RAM, storage, and graphics card piece by piece to build exactly what you need.

I’ll demonstrate how this pattern can make your Go code more readable and maintainable when dealing with complex object creation.

Let’s start with a scenario: HTTP Client Configuration

Imagine you’re building an HTTP client library that needs to support various configurations: timeouts, retries, authentication, custom headers, proxy settings, and more. Without the Builder pattern, your constructor might become unwieldy.

Without Builder Pattern

Here’s how you might handle this without the Builder pattern:

type HTTPClient struct {
    Timeout         time.Duration
    MaxRetries      int
    RetryDelay      time.Duration
    UserAgent       string
    Headers         map[string]string
    ProxyURL        string
    AuthToken       string
    FollowRedirects bool
    MaxRedirects    int
    TLSConfig       *tls.Config
}

// This constructor becomes unwieldy
func NewHTTPClient(timeout time.Duration, maxRetries int, retryDelay time.Duration, 
    userAgent string, headers map[string]string, proxyURL string, authToken string, 
    followRedirects bool, maxRedirects int, tlsConfig *tls.Config) *HTTPClient {
    
    return &HTTPClient{
        Timeout:         timeout,
        MaxRetries:      maxRetries,
        RetryDelay:      retryDelay,
        UserAgent:       userAgent,
        Headers:         headers,
        ProxyURL:        proxyURL,
        AuthToken:       authToken,
        FollowRedirects: followRedirects,
        MaxRedirects:    maxRedirects,
        TLSConfig:       tlsConfig,
    }
}

// Usage becomes confusing
func main() {
    client := NewHTTPClient(
        30*time.Second, // timeout
        3,              // maxRetries
        1*time.Second,  // retryDelay
        "MyApp/1.0",    // userAgent
        nil,            // headers
        "",             // proxyURL
        "token123",     // authToken
        true,           // followRedirects
        5,              // maxRedirects
        nil,            // tlsConfig
    )
    // What does each parameter mean? Hard to remember!
}

With Builder Pattern

Let’s refactor using the Builder pattern:

package main

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

// Product - the complex object we're building
type HTTPClient struct {
    client          *http.Client
    timeout         time.Duration
    maxRetries      int
    retryDelay      time.Duration
    userAgent       string
    headers         map[string]string
    authToken       string
    followRedirects bool
    maxRedirects    int
}

// Builder interface
type HTTPClientBuilder interface {
    SetTimeout(timeout time.Duration) HTTPClientBuilder
    SetRetries(maxRetries int, delay time.Duration) HTTPClientBuilder
    SetUserAgent(userAgent string) HTTPClientBuilder
    AddHeader(key, value string) HTTPClientBuilder
    SetHeaders(headers map[string]string) HTTPClientBuilder
    SetAuthToken(token string) HTTPClientBuilder
    SetRedirectPolicy(follow bool, maxRedirects int) HTTPClientBuilder
    SetTLSConfig(config *tls.Config) HTTPClientBuilder
    SetProxy(proxyURL string) HTTPClientBuilder
    Build() *HTTPClient
}

// Concrete Builder
type httpClientBuilder struct {
    client *HTTPClient
}

// Constructor for builder
func NewHTTPClientBuilder() HTTPClientBuilder {
    return &httpClientBuilder{
        client: &HTTPClient{
            timeout:         30 * time.Second,
            maxRetries:      3,
            retryDelay:      1 * time.Second,
            userAgent:       "Go-HTTP-Client/1.0",
            headers:         make(map[string]string),
            followRedirects: true,
            maxRedirects:    10,
        },
    }
}

func (b *httpClientBuilder) SetTimeout(timeout time.Duration) HTTPClientBuilder {
    b.client.timeout = timeout
    return b
}

func (b *httpClientBuilder) SetRetries(maxRetries int, delay time.Duration) HTTPClientBuilder {
    b.client.maxRetries = maxRetries
    b.client.retryDelay = delay
    return b
}

func (b *httpClientBuilder) SetUserAgent(userAgent string) HTTPClientBuilder {
    b.client.userAgent = userAgent
    return b
}

func (b *httpClientBuilder) AddHeader(key, value string) HTTPClientBuilder {
    b.client.headers[key] = value
    return b
}

func (b *httpClientBuilder) SetHeaders(headers map[string]string) HTTPClientBuilder {
    b.client.headers = headers
    return b
}

func (b *httpClientBuilder) SetAuthToken(token string) HTTPClientBuilder {
    b.client.authToken = token
    return b
}

func (b *httpClientBuilder) SetRedirectPolicy(follow bool, maxRedirects int) HTTPClientBuilder {
    b.client.followRedirects = follow
    b.client.maxRedirects = maxRedirects
    return b
}

func (b *httpClientBuilder) SetTLSConfig(config *tls.Config) HTTPClientBuilder {
    // This would be used when building the actual http.Client
    return b
}

func (b *httpClientBuilder) SetProxy(proxyURL string) HTTPClientBuilder {
    // This would be used when building the actual http.Client
    return b
}

func (b *httpClientBuilder) Build() *HTTPClient {
    // Create the actual http.Client with configured settings
    transport := &http.Transport{}
    
    client := &http.Client{
        Timeout:   b.client.timeout,
        Transport: transport,
    }
    
    if !b.client.followRedirects {
        client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        }
    }
    
    b.client.client = client
    return b.client
}

// Methods for the built HTTPClient
func (c *HTTPClient) Get(url string) (*http.Response, error) {
    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        return nil, err
    }
    
    // Add headers
    for key, value := range c.headers {
        req.Header.Set(key, value)
    }
    
    // Add user agent
    req.Header.Set("User-Agent", c.userAgent)
    
    // Add auth token if present
    if c.authToken != "" {
        req.Header.Set("Authorization", "Bearer "+c.authToken)
    }
    
    return c.client.Do(req)
}

func main() {
    // Much more readable and flexible!
    client := NewHTTPClientBuilder().
        SetTimeout(45 * time.Second).
        SetRetries(5, 2*time.Second).
        SetUserAgent("MyApp/2.0").
        AddHeader("Accept", "application/json").
        AddHeader("Content-Type", "application/json").
        SetAuthToken("secret-token-123").
        SetRedirectPolicy(true, 3).
        Build()
    
    fmt.Printf("HTTP Client configured with timeout: %v\n", client.timeout)
    fmt.Printf("Max retries: %d\n", client.maxRetries)
    fmt.Printf("User Agent: %s\n", client.userAgent)
    
    // Different configuration for another client
    apiClient := NewHTTPClientBuilder().
        SetTimeout(10 * time.Second).
        SetUserAgent("API-Client/1.0").
        AddHeader("X-API-Version", "v1").
        SetRedirectPolicy(false, 0).
        Build()
    
    fmt.Printf("\nAPI Client timeout: %v\n", apiClient.timeout)
    fmt.Printf("Follow redirects: %v\n", apiClient.followRedirects)
}

Advanced Builder with Validation

Let’s create a more sophisticated builder with validation and different build modes:

package main

import (
    "errors"
    "fmt"
    "net/url"
    "time"
)

// Product with validation
type DatabaseConfig struct {
    Host            string
    Port            int
    Database        string
    Username        string
    Password        string
    MaxConnections  int
    ConnectTimeout  time.Duration
    ReadTimeout     time.Duration
    WriteTimeout    time.Duration
    SSLMode         string
    RetryAttempts   int
    RetryDelay      time.Duration
}

// Validation method
func (d *DatabaseConfig) Validate() error {
    if d.Host == "" {
        return errors.New("host is required")
    }
    if d.Port <= 0 || d.Port > 65535 {
        return errors.New("port must be between 1 and 65535")
    }
    if d.Database == "" {
        return errors.New("database name is required")
    }
    if d.Username == "" {
        return errors.New("username is required")
    }
    if d.MaxConnections <= 0 {
        return errors.New("max connections must be positive")
    }
    return nil
}

// Builder with validation
type DatabaseConfigBuilder interface {
    SetHost(host string) DatabaseConfigBuilder
    SetPort(port int) DatabaseConfigBuilder
    SetDatabase(database string) DatabaseConfigBuilder
    SetCredentials(username, password string) DatabaseConfigBuilder
    SetConnectionPool(maxConnections int) DatabaseConfigBuilder
    SetTimeouts(connect, read, write time.Duration) DatabaseConfigBuilder
    SetSSLMode(mode string) DatabaseConfigBuilder
    SetRetryPolicy(attempts int, delay time.Duration) DatabaseConfigBuilder
    BuildForDevelopment() (*DatabaseConfig, error)
    BuildForProduction() (*DatabaseConfig, error)
    BuildForTesting() (*DatabaseConfig, error)
    Build() (*DatabaseConfig, error)
}

type databaseConfigBuilder struct {
    config *DatabaseConfig
    errors []error
}

func NewDatabaseConfigBuilder() DatabaseConfigBuilder {
    return &databaseConfigBuilder{
        config: &DatabaseConfig{
            Port:           5432,
            MaxConnections: 10,
            ConnectTimeout: 30 * time.Second,
            ReadTimeout:    30 * time.Second,
            WriteTimeout:   30 * time.Second,
            SSLMode:        "prefer",
            RetryAttempts:  3,
            RetryDelay:     1 * time.Second,
        },
        errors: make([]error, 0),
    }
}

func (b *databaseConfigBuilder) SetHost(host string) DatabaseConfigBuilder {
    if host == "" {
        b.errors = append(b.errors, errors.New("host cannot be empty"))
    } else {
        b.config.Host = host
    }
    return b
}

func (b *databaseConfigBuilder) SetPort(port int) DatabaseConfigBuilder {
    if port <= 0 || port > 65535 {
        b.errors = append(b.errors, errors.New("invalid port number"))
    } else {
        b.config.Port = port
    }
    return b
}

func (b *databaseConfigBuilder) SetDatabase(database string) DatabaseConfigBuilder {
    if database == "" {
        b.errors = append(b.errors, errors.New("database name cannot be empty"))
    } else {
        b.config.Database = database
    }
    return b
}

func (b *databaseConfigBuilder) SetCredentials(username, password string) DatabaseConfigBuilder {
    if username == "" {
        b.errors = append(b.errors, errors.New("username cannot be empty"))
    }
    b.config.Username = username
    b.config.Password = password
    return b
}

func (b *databaseConfigBuilder) SetConnectionPool(maxConnections int) DatabaseConfigBuilder {
    if maxConnections <= 0 {
        b.errors = append(b.errors, errors.New("max connections must be positive"))
    } else {
        b.config.MaxConnections = maxConnections
    }
    return b
}

func (b *databaseConfigBuilder) SetTimeouts(connect, read, write time.Duration) DatabaseConfigBuilder {
    if connect <= 0 || read <= 0 || write <= 0 {
        b.errors = append(b.errors, errors.New("timeouts must be positive"))
    } else {
        b.config.ConnectTimeout = connect
        b.config.ReadTimeout = read
        b.config.WriteTimeout = write
    }
    return b
}

func (b *databaseConfigBuilder) SetSSLMode(mode string) DatabaseConfigBuilder {
    validModes := map[string]bool{
        "disable": true, "allow": true, "prefer": true, "require": true,
    }
    if !validModes[mode] {
        b.errors = append(b.errors, errors.New("invalid SSL mode"))
    } else {
        b.config.SSLMode = mode
    }
    return b
}

func (b *databaseConfigBuilder) SetRetryPolicy(attempts int, delay time.Duration) DatabaseConfigBuilder {
    if attempts < 0 {
        b.errors = append(b.errors, errors.New("retry attempts cannot be negative"))
    }
    if delay < 0 {
        b.errors = append(b.errors, errors.New("retry delay cannot be negative"))
    }
    b.config.RetryAttempts = attempts
    b.config.RetryDelay = delay
    return b
}

// Preset configurations for different environments
func (b *databaseConfigBuilder) BuildForDevelopment() (*DatabaseConfig, error) {
    return b.SetHost("localhost").
        SetPort(5432).
        SetDatabase("myapp_dev").
        SetCredentials("dev_user", "dev_password").
        SetConnectionPool(5).
        SetSSLMode("disable").
        Build()
}

func (b *databaseConfigBuilder) BuildForProduction() (*DatabaseConfig, error) {
    return b.SetConnectionPool(50).
        SetTimeouts(10*time.Second, 30*time.Second, 30*time.Second).
        SetSSLMode("require").
        SetRetryPolicy(5, 2*time.Second).
        Build()
}

func (b *databaseConfigBuilder) BuildForTesting() (*DatabaseConfig, error) {
    return b.SetHost("localhost").
        SetPort(5433).
        SetDatabase("myapp_test").
        SetCredentials("test_user", "test_password").
        SetConnectionPool(2).
        SetSSLMode("disable").
        SetRetryPolicy(1, 100*time.Millisecond).
        Build()
}

func (b *databaseConfigBuilder) Build() (*DatabaseConfig, error) {
    // Check for builder errors first
    if len(b.errors) > 0 {
        return nil, fmt.Errorf("builder errors: %v", b.errors)
    }
    
    // Validate the final configuration
    if err := b.config.Validate(); err != nil {
        return nil, fmt.Errorf("validation error: %w", err)
    }
    
    return b.config, nil
}

// Connection string generator
func (d *DatabaseConfig) ConnectionString() string {
    u := url.URL{
        Scheme: "postgres",
        User:   url.UserPassword(d.Username, d.Password),
        Host:   fmt.Sprintf("%s:%d", d.Host, d.Port),
        Path:   d.Database,
    }
    
    query := u.Query()
    query.Set("sslmode", d.SSLMode)
    query.Set("connect_timeout", fmt.Sprintf("%.0f", d.ConnectTimeout.Seconds()))
    u.RawQuery = query.Encode()
    
    return u.String()
}

func main() {
    // Development configuration
    devConfig, err := NewDatabaseConfigBuilder().BuildForDevelopment()
    if err != nil {
        fmt.Printf("Error building dev config: %v\n", err)
        return
    }
    
    fmt.Println("Development Configuration:")
    fmt.Printf("Connection String: %s\n", devConfig.ConnectionString())
    fmt.Printf("Max Connections: %d\n", devConfig.MaxConnections)
    fmt.Println()
    
    // Production configuration with custom settings
    prodConfig, err := NewDatabaseConfigBuilder().
        SetHost("prod-db.example.com").
        SetPort(5432).
        SetDatabase("myapp_prod").
        SetCredentials("prod_user", "secure_password").
        BuildForProduction()
    
    if err != nil {
        fmt.Printf("Error building prod config: %v\n", err)
        return
    }
    
    fmt.Println("Production Configuration:")
    fmt.Printf("Host: %s:%d\n", prodConfig.Host, prodConfig.Port)
    fmt.Printf("SSL Mode: %s\n", prodConfig.SSLMode)
    fmt.Printf("Max Connections: %d\n", prodConfig.MaxConnections)
    fmt.Println()
    
    // Testing configuration
    testConfig, err := NewDatabaseConfigBuilder().BuildForTesting()
    if err != nil {
        fmt.Printf("Error building test config: %v\n", err)
        return
    }
    
    fmt.Println("Testing Configuration:")
    fmt.Printf("Database: %s\n", testConfig.Database)
    fmt.Printf("Retry Policy: %d attempts, %v delay\n", 
        testConfig.RetryAttempts, testConfig.RetryDelay)
    
    // Example of validation error
    fmt.Println("\nValidation Error Example:")
    _, err = NewDatabaseConfigBuilder().
        SetHost("").  // Invalid empty host
        SetPort(-1).  // Invalid port
        Build()
    
    if err != nil {
        fmt.Printf("Expected error: %v\n", err)
    }
}

Functional Builder Pattern (Go Idiomatic)

Here’s a more Go-idiomatic approach using functional options:

package main

import (
    "fmt"
    "time"
)

// Product
type Server struct {
    Host         string
    Port         int
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
    MaxConns     int
    TLS          bool
}

// Option function type
type ServerOption func(*Server)

// Option functions (builders)
func WithHost(host string) ServerOption {
    return func(s *Server) {
        s.Host = host
    }
}

func WithPort(port int) ServerOption {
    return func(s *Server) {
        s.Port = port
    }
}

func WithTimeouts(read, write time.Duration) ServerOption {
    return func(s *Server) {
        s.ReadTimeout = read
        s.WriteTimeout = write
    }
}

func WithMaxConnections(max int) ServerOption {
    return func(s *Server) {
        s.MaxConns = max
    }
}

func WithTLS() ServerOption {
    return func(s *Server) {
        s.TLS = true
    }
}

// Constructor with functional options
func NewServer(options ...ServerOption) *Server {
    // Default configuration
    server := &Server{
        Host:         "localhost",
        Port:         8080,
        ReadTimeout:  30 * time.Second,
        WriteTimeout: 30 * time.Second,
        MaxConns:     100,
        TLS:          false,
    }
    
    // Apply options
    for _, option := range options {
        option(server)
    }
    
    return server
}

func (s *Server) Start() {
    protocol := "HTTP"
    if s.TLS {
        protocol = "HTTPS"
    }
    
    fmt.Printf("Starting %s server on %s:%d\n", protocol, s.Host, s.Port)
    fmt.Printf("Read timeout: %v, Write timeout: %v\n", s.ReadTimeout, s.WriteTimeout)
    fmt.Printf("Max connections: %d\n", s.MaxConns)
}

func main() {
    // Default server
    defaultServer := NewServer()
    defaultServer.Start()
    fmt.Println()
    
    // Custom server with options
    customServer := NewServer(
        WithHost("0.0.0.0"),
        WithPort(443),
        WithTLS(),
        WithTimeouts(10*time.Second, 15*time.Second),
        WithMaxConnections(1000),
    )
    customServer.Start()
    fmt.Println()
    
    // Another configuration
    apiServer := NewServer(
        WithPort(3000),
        WithMaxConnections(500),
    )
    apiServer.Start()
}

Real-world Use Cases

I frequently use the Builder pattern in these scenarios:

  1. Configuration Objects: Database connections, HTTP clients, servers
  2. Query Builders: SQL query construction, search filters
  3. Test Data Builders: Creating complex test objects
  4. API Request Builders: Building complex API requests
  5. Report Generators: Configuring complex reports with multiple options
  6. Email Builders: Constructing emails with attachments, templates, etc.

Benefits of Builder Pattern

  1. Readability: Method chaining makes code self-documenting
  2. Flexibility: Easy to add new configuration options
  3. Immutability: Can create immutable objects step by step
  4. Validation: Can validate during construction
  5. Default Values: Provides sensible defaults

Caveats

Consider these potential issues:

  1. Complexity: Adds more code for simple objects
  2. Memory: Builder objects consume additional memory
  3. Thread Safety: Builders are typically not thread-safe
  4. Incomplete Objects: Risk of forgetting to call Build()

Thank you

The Builder pattern is incredibly useful for creating complex objects in Go. Whether you use the traditional approach or Go’s functional options pattern, it makes your code more readable and maintainable. Please drop an email at [email protected] if you would like to share any feedback or suggestions. Peace!