Software design patterns: the Builder pattern in Go
Dec 23, 2023
4 min read
The builder pattern is a technique where a developer uses a "builder" to construct an object. The end result could be anything - a string, a struct instance, or even a closure - but it is built using a builder. More often than not a builder is used because the object being created is complex and needs to be constructed in multiple steps, so the builder helps isolate each step and prevent bugs.
Imagine a complex object that requires laborious, step-by-step initialization of many fields and nested objects. Such initialization code is usually buried inside a monstrous constructor with lots of parameters. Or even worse: scattered all over the client code. Here is where the builder pattern comes to the rescue.
To illustrate the idea, I'll provide an easy example, for what the Builder pattern would be an overkill, but it'll help us understand this. Obviously, as the title of this article says, I'll be using Go, but note that this is an universal software design pattern, so it can be applied to any language that supports some kind of Object Oriented Programming.
Naive example
Imagine you have an Car
struct:
type Car struct {
Model string
Type string
Traction string
}
Under most circumstances a developer would create an instance of the Car
struct in their code and assign
a value to each of these fields, but in some circumstances you might want to do more than that. For instance,
you might decide that when the Type
field is set you can also assign the Traction
field based on
the car's type. This could be achieved using a builder.
type Builder struct {
c Car
}
func (b *Builder) Build() Car {
return b.c
}
func (b *Builder) Model(model string) *Builder {
b.c.Model = model
return b
}
func (b *Builder) Type(carType string) *Builder {
if carType == "SUV" {
b.c.Traction = "4x4"
} else {
b.c.Traction = "front"
}
b.c.Type = carType
return b
}
Now when we are constructing a Car
we can use the builder to ensure that the Traction
gets
set when we set the Type
field. We could even add validation in and verify that the Type
is a valid one - though at
that point we would need to also return an error.
Below we can see how this builder might be used (Go Playground link):
b := &Builder{}
car := b.
Model("Toyota Rav4").
Type("SUV").
Build()
fmt.Println(car)
// {Toyota Rav4 SUV 4x4}
The magic of the builder pattern is that we can chain the builder methods together, and each method returns
the builder itself. This allows us to call the next method on the builder, and so on. The last method called
is the Build
method, which returns the final object.
As said, this is a really basic example so the builder pattern might feel like overkill, but much more complex scenarios exist where the builder pattern is incredibly helpful. For instance, constructing SQL queries can become complex and we might need to handle conditional queries. In this case a builder can simplify things a bit.
Real-world™ examples
Indeed, most SQL query builder libs out there implement this pattern. One of such libraries is squirrel.
Let's take a look at a few examples from the squirrel docs at the moment of writing this article. First, we start with a basic example of how squirrel can be used to generate the SQL query string we might use with the standard
library's database/sql
package.
users := sq.Select("*").From("users").Join("emails USING (email_id)")
sql, args, err := users.ToSql()
// sql == "SELECT * FROM users JOIN emails USING (email_id)"
active := users.Where(sq.Eq{"deleted_at": nil})
sql, args, err := active.ToSql()
// sql == "SELECT * FROM users JOIN emails USING (email_id) WHERE deleted_at IS NULL"
Now, let's move to a more complex scenario. Let's say we want to filter by name is a certain variable q
has content, which is as simple as adding an if statement and adding
the conditional query
if len(q) > 0 {
users = users.Where("name LIKE ?", fmt.Sprint("%", q, "%"))
}
Notice how we assign the result of the users.Where
function call to the users
variable, allowing us to retain this
new build step. Now whenever we call users.ToSql()
(that acts like the Build()
function in the Car example) it will have the Where
clause here only if the if statement
was true.
Builders can even be found in Go's standard library too. See text/template
and html/template
packages.
Both of the template packages have a Template
type that uses the builder pattern:
tmpl, err := template.New("titleTest").
Funcs(funcMap).
Delims("[[", "]]").
Parse(templateText)
Both Funcs
and Delims
return a *Template
, and Parse
returns both a *Template and an error kinda like our Role
example in the employee builder would have if we validated the role.
Conclusion
To sum up - the builder pattern usually involves chaining functions on a single type as we "build" the end result. It is used to build complex objects, and can be found in quite a few libraries.
If you want to go deeper into the Builder pattern and others, I invite you to take a look at the refactoring.guru page. It's a great resource to learn about software design patterns. Also, if you want to read more from me, you can check other patterns articles in my blog.