How to Implement the Builder Pattern in Go

Implement the Builder Pattern in Go using a struct with pointer receivers and chaining setter methods that return the builder instance.

The problem with too many arguments

You are writing a configuration struct for a database client. It needs a host, a port, a username, a password, a timeout, a max connection count, a retry policy, and a TLS certificate path. Most of these have sensible defaults. The user only cares about the host and the password. You try to pass all ten arguments to a constructor function and end up with NewClient("localhost", 5432, "admin", "secret", 30, 100, nil, nil, nil, nil). The caller has to remember the order. The caller has to pass nil for everything they don't care about. The code is fragile. Add a new field and every call site breaks.

Go does not have named arguments or default parameter values. A struct literal lets you skip fields, but it cannot enforce validation or set defaults dynamically. You can write Client{Host: "localhost"}, but if Host is empty, the client might crash later when it tries to connect. You need a way to guide the caller, apply defaults, check invariants, and keep the API readable.

Builders turn construction into a conversation

The builder pattern solves this by turning construction into a series of steps. Instead of one giant function call, you get a helper object. You set the fields you care about. You leave the rest alone. Finally, you ask for the result.

Think of it like ordering a pizza at a kiosk. You do not specify "medium, round, thin crust, standard sauce, no anchovies, standard cheese." You just tap "pepperoni" and "extra cheese." The system fills in the defaults for everything else. In Go, this usually means a struct with methods that return a pointer to themselves, allowing you to chain calls. The builder accumulates state, applies logic, and produces the final object only when you are ready.

The minimal builder

Here is the core definition. The builder struct wraps the target type and provides a constructor that returns a fresh instance.

// User holds the final data structure.
type User struct {
	Name string
	Age  int
}

// UserBuilder accumulates values before creating a User.
type UserBuilder struct {
	u User
}

// NewUserBuilder returns a fresh builder with zero values.
func NewUserBuilder() *UserBuilder {
	return &UserBuilder{}
}

// WithName sets the name and returns the builder for chaining.
func (b *UserBuilder) WithName(name string) *UserBuilder {
	b.u.Name = name
	return b
}

The setter methods mutate the internal state and return the pointer to enable chaining. The Build method extracts the result.

// WithAge sets the age and returns the builder for chaining.
func (b *UserBuilder) WithAge(age int) *UserBuilder {
	b.u.Age = age
	return b
}

// Build returns the constructed User.
func (b *UserBuilder) Build() User {
	return b.u
}

func main() {
	// Chain setters to configure only the fields that matter.
	user := NewUserBuilder().WithName("Alice").WithAge(30).Build()
	// user.Name is "Alice", user.Age is 30.
}

The builder is a factory. The product is the struct. Keep them separate.

How the chain holds together

The magic relies on pointer receivers. When you call WithName, the method receives a pointer to the builder. It modifies the struct fields in place. It returns that same pointer. The next method call receives the modified pointer. This continues until Build is called.

If the receiver were a value, the method would modify a copy. The changes would vanish when the method returns. The chain would produce a struct with all zero values. Pointer receivers are mandatory for mutation.

The return type of each setter is also a pointer. This allows the result of one call to be the receiver of the next. If a setter returned a value, the chain would still work, but it would copy the builder on every step. For a small builder, the copy cost is negligible. However, returning a pointer is the convention because it signals that the object is being mutated and reused.

Build returns the value, not a pointer. This breaks the chain and gives you the final object. If Build returned a pointer, you could keep chaining, which defeats the purpose. The builder is a tool for construction, not the result itself.

The receiver name follows Go convention. Use one or two letters matching the type. (b *UserBuilder) is standard. Avoid (this *UserBuilder) or (self *UserBuilder). The compiler does not care, but the community expects brevity.

Pointers enable mutation. Returns enable chaining. Both are required.

Real-world builders validate and default

Real builders often handle defaults and validation. A struct literal with defaults is common in Go, but a builder can enforce invariants that depend on multiple fields. Here is a builder that sets defaults upfront. The constructor returns a pre-configured state, so the caller only touches fields that differ from the norm.

// Client represents an HTTP client with configurable options.
type Client struct {
	Timeout time.Duration
	Retries int
}

// ClientBuilder helps construct a Client with defaults and validation.
type ClientBuilder struct {
	c Client
}

// NewClientBuilder initializes a builder with sensible defaults.
func NewClientBuilder() *ClientBuilder {
	// Pre-fill defaults so the caller only overrides what changes.
	return &ClientBuilder{
		c: Client{
			Timeout: 30 * time.Second,
			Retries: 3,
		},
	}
}

The setters override specific values. The Build method runs validation before returning the result. Returning an error from Build is a common pattern to catch configuration mistakes early.

// WithTimeout overrides the default timeout.
func (b *ClientBuilder) WithTimeout(d time.Duration) *ClientBuilder {
	b.c.Timeout = d
	return b
}

// WithRetries overrides the retry count.
func (b *ClientBuilder) WithRetries(n int) *ClientBuilder {
	b.c.Retries = n
	return b
}

// Build validates configuration and returns the Client.
func (b *ClientBuilder) Build() (*Client, error) {
	// Enforce invariants that a struct literal cannot check.
	if b.c.Retries < 0 {
		return nil, fmt.Errorf("retries cannot be negative")
	}
	return &b.c, nil
}

The Build method returns an error. The caller must check it. The standard response is if err != nil { return err }. This looks repetitive, but it forces you to handle configuration failures explicitly. Go prefers visible error handling over silent failures.

The builder returns a concrete struct pointer. This aligns with the mantra "accept interfaces, return structs." The caller gets the full type with all its methods. If you returned an interface, you would hide implementation details unnecessarily and make testing harder.

Defaults save the caller. Validation saves the system.

Pitfalls and gotchas

Builders hold mutable state. If you reuse a builder, you might carry over state from the previous build. Some builders reset their internal state in Build. Others do not. Be explicit about reusability. If the builder is not safe to reuse, document it or return a new builder from Build.

A common mistake is forgetting to return the builder in a setter. If you omit the return b statement, the method returns nil implicitly. The next call in the chain panics with runtime error: invalid memory address or nil pointer dereference. The compiler does not catch this because the return type matches. You must ensure every setter returns the pointer.

Another risk is returning a pointer to the internal struct. If Build returns &b.c, the caller can modify the builder's internal state after construction. This breaks encapsulation. Return a copy of the value or a new pointer to a copied struct if you want to protect the builder.

Do not use pointers for simple optional fields. A field like *string suggests the value might be nil, but strings are cheap to pass by value. Use a plain string and let the zero value be empty. If you need to distinguish "not set" from "empty string", use a wrapper type or a separate boolean flag. Pointers add allocation overhead and nil checks without much benefit for small types.

The builder code will look the same everywhere because gofmt handles the indentation. You do not need to argue about where the chain breaks or how many spaces to use. Trust the formatter. Focus on the logic.

Builders are mutable state. Treat them as temporary unless you design for reuse.

Choosing the right construction pattern

Go offers several ways to construct objects. Pick the simplest tool that fits the job.

Use a builder when you have many optional fields and need validation or defaults that a struct literal cannot enforce.

Use a struct literal when the struct has few fields or all fields are required, making positional arguments unnecessary.

Use functional options when you need to compose configuration from multiple packages or plugins without exposing a mutable builder type.

Use a simple constructor function when the configuration logic is trivial and chaining adds no value.

Simplicity wins. Don't build a builder for three fields.

Where to go next