Skip to Content
Course content

232: Builder Pattern in Go

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of my career cleaning up "constructor bloat." You know the pattern: you start with a simple NewClient function that takes two arguments. Then the project grows. Suddenly, you need a timeout. Then a retry policy. Then a custom logger, a proxy setting, and a trace ID. Before you know it, you're staring at a function signature with twelve arguments, half of which are nil or 0 because the caller doesn't actually need them.

The Nightmare of the Telescoping Constructor

Let's look at how most people start. Imagine we're building a DatabaseConnection object. It's a critical piece of infrastructure, so it needs a lot of configuration. The naive approach is to just keep adding parameters to the factory function:

type DatabaseConnection struct {
    Host     string
    Port     int
    User     string
    Password string
    Timeout  time.Duration
    SSL      bool
}

func NewDatabaseConnection(host string, port int, user string, pass string, timeout time.Duration, ssl bool) *DatabaseConnection {
    return &DatabaseConnection{
        Host: host, Port: port, User: user, Password: pass, Timeout: timeout, SSL: ssl,
    }
}

This is a maintenance disaster. If I want to add a MaxConnections field, I have to touch every single line of code in the entire repository that calls NewDatabaseConnection. Even worse, look at the call site. If I see NewDatabaseConnection("localhost", 5432, "admin", "secret", 30, true), I have no idea what 30 or true actually means without jumping back to the definition. I've definitely swapped two integer arguments in my sleep and spent an hour debugging why my port was set to 30 seconds.

Fluent Interfaces and the Builder's Logic

The Builder pattern fixes this by separating the construction of the object from the representation of the object. Instead of one massive function, we create a helper struct—the Builder—that collects the configuration step-by-step. The trick in Go is to return the builder itself from every setter method, creating a "fluent interface" that reads like a sentence.

type ConnectionBuilder struct {
    conn *DatabaseConnection
}

func NewConnectionBuilder() *ConnectionBuilder {
    // We set our sensible defaults right here
    return &ConnectionBuilder{
        conn: &DatabaseConnection{
            Port:    5432,
            Timeout: 30 * time.Second,
            SSL:     true,
        },
    }
}

func (b *ConnectionBuilder) WithHost(host string) *ConnectionBuilder {
    b.conn.Host = host
    return b
}

func (b *ConnectionBuilder) WithPort(port int) *ConnectionBuilder {
    b.conn.Port = port
    return b
}

func (b *ConnectionBuilder) WithTimeout(t time.Duration) *ConnectionBuilder {
    b.conn.Timeout = t
    return b
}

Now, the caller only specifies what they actually care about. The noise disappears. I can see exactly what's being configured, and if I add a new option to the builder, I don't break a single existing call site. It's a much more stable way to evolve an API.

When the Build Actually Happens

The most important part of this pattern isn't the setters; it's the Build() method. This is where we move from "gathering data" to "creating a valid object." If you just use a public struct and let users set fields manually, you can't guarantee the object is in a valid state. The Builder acts as a gatekeeper.

func (b *ConnectionBuilder) Build() (*DatabaseConnection, error) {
    if b.conn.Host == "" {
        return nil, errors.New("host is required to establish a connection")
    }
    if b.conn.Port <= 0 {
        return nil, errors.New("invalid port number")
    }
    
    return b.conn, nil
}

I like this approach because it encapsulates the validation logic. The user doesn't have to wonder, "Did I remember to set the host?" They just call Build(), and the builder tells them exactly what they missed. You get a clean, immutable-feeling object at the end, and the messy logic of "how to put it together" is hidden away in the builder.

One quick word of caution: don't use this for everything. If your struct only has two or three fields that are always required, a simple New... function is better. The Builder pattern is a tool for complexity. If you use it for simple objects, you're just adding boilerplate for the sake of it, and your teammates will probably call you out on it during the PR.




📋 Practical Task

Exercise: Implementing a Custom HTTP Request Builder

You need to create a RequestBuilder to simplify the creation of complex HTTP requests. Instead of manually filling out a request struct, the user should be able to chain methods to configure the request.

Requirements:

  • Create a HTTPRequest struct with fields for Method (string), URL (string), Timeout (time.Duration), and Headers (a map of strings).
  • Implement a RequestBuilder that provides:
    • NewRequestBuilder(): Initializes a builder with a default method of "GET" and a default timeout of 10 seconds.
    • WithURL(url string): Sets the target URL.
    • WithMethod(method string): Overrides the default HTTP method.
    • WithHeader(key, value string): Adds a key-value pair to the headers map.
    • Build(): Returns the final HTTPRequest and an error.
  • The Build() method must return an error if the URL has not been set.

Test your implementation by building a "POST" request to "https://api.example.com/v1/data" with a "Content-Type: application/json" header and a custom timeout of 5 seconds.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.