GO design patterns: Builder

Dan Stenger
4 min readJan 31, 2023

--

Photo by Mitchell Luo on Unsplash

One way to become very good at something is to practice repeatedly. Like Depeche Mode said in one of their songs: “I like to practice what I (p)reach”. Each time I encounter the same or a similar problem, I find a better, more elegant solution. If you have read my previous articles, you know I am fond of Go. This time, I’d like to start a series of articles on design patterns in Go and will begin with the one I use most frequently — the Builder pattern.

I frequently work with REST APIs and sometimes the response that I have to construct and serve to the end user is a complex data structure with many optional properties. In such cases, I avoid constructing the response within a single method call and instead use the builder pattern. This keeps my code clean and organized, and also allows me to easily test isolated bits of logic.

Let’s consider the following relatively simple example: a response that will include user, account, and address details.

type Response struct {
User User
Account Account
Address Address
}

type User struct {
Email string
}

type Account struct {
Balance float64
}

type Address struct {
City string
}

Imagine I have obtained all the data from a database or some other sources and now I need to map it to a Response struct using the Builder pattern. To do this, I will use a new struct named ResponseBuilder

type ResponseBuilder struct {
Response Response
}

func NewBuilder() *ResponseBuilder {
return &ResponseBuilder{
Response: Response{},
}
}

The NewBuilder function is a convenient method that returns a pointer to a ResponseBuilder with all the expected zero value data structures initialized. With this in place, I can start adding helper methods to the builder for constructing my response.

func (rb *ResponseBuilder) SetEmail(email string) {
// can perform email validation here if needed
rb.Response.User.Email = email
}

func (rb *ResponseBuilder) SetBalance(bal float64) {
rb.Response.Account.Balance = bal
}

func (rb *ResponseBuilder) SetCity(city string) {
rb.Response.Address.City = city
}

func (rb *ResponseBuilder) Build() Response {
return rb.Response
}

func main() {
rb := NewBuilder()
rb.SetEmail("foo@bar.com")
rb.SetBalance(100.54)
rb.SetCity("London")
fmt.Printf("response %+v\n", rb.Build())
}

And that is it for a basic example. Although it might not look like much, now imagine that each method has some additional logic, such as validation on the value being passed in. In that case, each function will still only do one thing and do it well, which is a good practice.

Can it get any better than this? It depends. I could apply the Fluent Interface Design Pattern to allow method chaining and further reduce the amount of code in the main function. The implementation would look like this:

package main

type ResponseBuilder struct {
Response Response
}

func NewBuilder() *ResponseBuilder {
return &ResponseBuilder{
Response: Response{},
}
}

type Response struct {
User User
Account Account
Address Address
}

type User struct {
Email string
}

type Account struct {
Balance float64
}

type Address struct {
City string
}

func (rb *ResponseBuilder) SetEmail(email string) *ResponseBuilder {
// can perform email validation here if needed
rb.Response.User.Email = email
return rb
}

func (rb *ResponseBuilder) SetBalance(bal float64) *ResponseBuilder {
rb.Response.Account.Balance = bal
return rb
}

func (rb *ResponseBuilder) SetCity(city string) *ResponseBuilder {
rb.Response.Address.City = city
return rb
}

func (rb *ResponseBuilder) Build() Response {
return rb.Response
}

func main() {
rb := NewBuilder().
SetEmail("foo@bar.com").
SetBalance(100.54).
SetCity("London")
fmt.Printf("response %+v\n", rb.Build())
}

Arguably, the above example is a bit better. Each ResponseBuilder method now assigns a value, as it did previously, and returns a pointer to itself, which allows me to chain method calls.

In some scenarios, you might end up with more complex data structures and many more fields to be assigned to the response. In cases like this, it can negatively impact readability. To make it easier to follow, we can break the main builder into multiple smaller ones, each responsible for its own subset of data. Consider the following example:

package main

type Response struct {
User User
Account Account
Address Address
}

type User struct {
Email string
}

type Account struct {
Balance float64
}

type Address struct {
City string
}

type ResponseBuilder struct {
Response Response
}

func NewBuilder() *ResponseBuilder {
return &ResponseBuilder{
Response: Response{},
}
}

func (rb *ResponseBuilder) User() *UserBuilder {
return &UserBuilder{*rb}
}

func (rb *ResponseBuilder) Account() *AccountBuilder {
return &AccountBuilder{*rb}
}

func (rb *ResponseBuilder) Address() *AddressBuilder {
return &AddressBuilder{*rb}
}

func (rb *ResponseBuilder) Build() Response {
return rb.Response
}

type UserBuilder struct {
ResponseBuilder
}

func (ub *UserBuilder) Email(email string) *UserBuilder {
ub.Response.User.Email = email
return ub
}

type AccountBuilder struct {
ResponseBuilder
}

func (ab *AccountBuilder) Balance(bal float64) *AccountBuilder {
ab.Response.Account.Balance = bal
return ab
}

type AddressBuilder struct {
ResponseBuilder
}

func (addrb *AddressBuilder) City(city string) *AddressBuilder {
addrb.Response.Address.City = city
return addrb
}

func main() {
rb := NewBuilder().
User().
Email("foo@bar.com")
Account().
Balance(100.54).
Address().
City("London")
fmt.Printf("response %+v\n", rb.Build())
}

In the above example, I’ve broken down a single builder into multiple builders, each dealing with its own subset of data, but all utilizing the same ResponseBuilder and having access to Response struct.

You won’t see much value in such a basic example, but when requirements grow and there is a lot of data to work with, this approach might become useful as it becomes more clear what data is being assigned where.

And that’s all folks! The Builder pattern is a great way to keep your code organized and maintainable, and with a few modifications, it can make your life easier and your code cleaner.

--

--

Dan Stenger

Software engineer focusing on simplicity and reliability. GO and functional programming enthusiast