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:
- Configuration Objects: Database connections, HTTP clients, servers
- Query Builders: SQL query construction, search filters
- Test Data Builders: Creating complex test objects
- API Request Builders: Building complex API requests
- Report Generators: Configuring complex reports with multiple options
- Email Builders: Constructing emails with attachments, templates, etc.
Benefits of Builder Pattern
- Readability: Method chaining makes code self-documenting
- Flexibility: Easy to add new configuration options
- Immutability: Can create immutable objects step by step
- Validation: Can validate during construction
- Default Values: Provides sensible defaults
Caveats
Consider these potential issues:
- Complexity: Adds more code for simple objects
- Memory: Builder objects consume additional memory
- Thread Safety: Builders are typically not thread-safe
- 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!