DEV Community

Jon Calhoun
Jon Calhoun

Posted on • Edited on • Originally published at calhoun.io

Using functional options instead of method chaining in Go

This post was original posted at calhoun.io

In Java, it is pretty common to see libraries that use method chaining (aka the builder pattern) to construct resources. For example, we might construct a user by doing something like:

User user = new User.Builder()
  .name("Michael Scott")
  .email("michael@dundermifflin.com")
  .role("manager")
  .nickname("Best Boss")
  .build();
Enter fullscreen mode Exit fullscreen mode

Builders are handy for a variety of reasons, but in the example above we are using a builder in order to define a subset of our User attributes before constructing the user object. This is very handy in a language like Java where you have to define each constructor individually.

public class User {
  public User() {}
  public User(String name) {
    this.name = name
  }
  public User(String name, String email) {
    this.name = name
    this.email = email
  }
  // ... plus many more
}
Enter fullscreen mode Exit fullscreen mode

As you can see, this list of constructors would become obnoxious very quickly so builders are used instead.

Go doesn't have this problem because it has struct literals. We can easily define a subset of a types attributes when constructing it, making builders fairly useless if nothing fancy is going on.

Where the builder pattern does tend to pop up in Go is when you need to set additional data beyond the fields being defined. For instance, when using any SQL building library it is common to chain multiple WHERE, OR, NOT, and other similar statements together in order to create a complete SQL query. Using struct literals etc wouldn't work so well here because there is a lot of complexity behind the scenes with each function you call, and it isn't always possible to resolve everything until all of the chained methods have been called.

In order for this to work, your Go code needs to return the "builder" object after every single method call, otherwise chaining simply wouldn't work. A simplified example of this in Go is shown below.

Note: I'm not advocating the use of this pattern, and this is a very contrived example, but it serves the purpose of illustrating how the method chaining works.

package main

import "fmt"

func main() {
  ub := &UserBuilder{}
  user := ub.
    Name("Michael Scott").
    Role("manager").
    Build()
  fmt.Println(user)
}

type User struct {
  Name      string
  Role      string
  MinSalary int
  MaxSalary int
}

type UserBuilder struct {
  User
}

func (ub *UserBuilder) Build() User {
  return ub.User
}

func (ub *UserBuilder) Name(name string) *UserBuilder {
  ub.User.Name = name
  return ub
}

func (ub *UserBuilder) Role(role string) *UserBuilder {
  // verify the role is valid
  if role == "manager" {
    ub.User.MinSalary = 20000
    ub.User.MaxSalary = 60000
  }
  ub.User.Role = role
  return ub
}
Enter fullscreen mode Exit fullscreen mode

Run it on the Go Playground

The example above is pretty contrived because we are working with a relatively simple resource, but as I said before that isn't always the case. For example, libraries like GORM and pop both rely on method chaining in order to build more complex SQL queries.

While this pattern tends to works well enough in these libraries, there are definitely times where it doesn't feel like we are writing code the Go way, and I believe this typically stems from the way error handling has to be done.

In a language like Java, where the builder pattern is very prevalent, you can throw exceptions from anywhere. While this may seem insignificant at first, what it ultimately means is that any single method in your builder chain has the option of throwing an exception, but if there are no exceptions the method can still simply return the builder class so that method chaining can continue.

In Go this is not possible. The only way to notify end users of an error in Go is to return an error, but when all of our builder methods only return the builder to permit method chaining this isn't possible. Instead, we have to defer notifying the user of any potential errors until later in the process.

For example, if we were to call something like the query below (which is missing an argument representing the ID to be queried) we couldn't be notified of it immediately in Go.

db.Where("id = ?")...
Enter fullscreen mode Exit fullscreen mode

As I said earlier, in Java we would simply throw an exception. The Go way to handle this would be to return an error, but by introducing a second return variable (the error) we end up with code that can no longer support method chaining.

var db *gorm.DB
var err error
db, err = db.Where("id = ?", 123)
if err != nil { ... }
db, err = db.Where("email = ?", "jon@calhoun.io")
if err != nil { ... }
// ... and so on
Enter fullscreen mode Exit fullscreen mode

As you can see, this wouldn't be a very fun API to use.

Now if you really want to use method chaining, there are a few ways to make returning errors possible. For example, GORM gets around this by attaching an Error field to the *gorm.DB type that will get set whenever there is an error in the method chain. By doing this, GORM can now set an error when one occurs and then subsequent method calls could terminate early because there has already been an error. It isn't perfect, but it works, and you end up with code like below.

// ignore this error for a simpler example
db, _ := gorm.Open(...)
var user User
err := db.Where("email = ?", "jon@calhoun.io").
  Where("age >= ?", 18).
  First(&user).
  Error
Enter fullscreen mode Exit fullscreen mode

While this does work, there are a few things about this code that make it suboptimal.

The first is that none of the methods we call (Where and First) ever return an error, so it isn't immediately clear that we even need to worry about an error.

The second issue with this code is that it is easy to write buggy code and miss an error. For example if we were to instead write the following code we would always get nil for our error because we aren't actually checking the correct instance of gorm.DB.

// ignore this error for a simpler example
db, _ := gorm.Open(...)
var user User
db.Where("email = ?", "jon@calhoun.io").
  Where("age >= ?", 18).
  First(&user)
// db.Error will ALWAYS be nil!
if db.Error != nil {
  // Handle the error
}
Enter fullscreen mode Exit fullscreen mode

In order for this pattern to work, we have to either call Error at the end of our chain, or we need to capture the resulting gorm.DB instance returned by the First method so that we can check it for an error.

This is done intentionally by GORM because the library would be much harder to use if you had to manually clone your gorm.DB instance before making any queries with, so instead GORM handles cloning it every time you call a chaining method like Where or First. The end result is that you write less code, but it is easier to miss errors if you don't understand this fully.

*Note: One way to fix this would be to update methods like First and Find in GORM to return both the gorm.DB and an error when called, but I'm not 100% sure how this would affect the rest of the library. Instead we are going to explore an alternative approach that I prefer anyway

An alternative approach - functional options

Rather than using method chaining, I find that it is much easier to use functional options, a term I first heard coined by Dave Cheney (in the linked blog post).

At a high level, functional options are basically just arguments to a method or function call that happen to be functions. They might be closures created dynamically, or they might be static functions; that is mostly irrelevant. What IS important is that we aren't passing in data, but are instead passing in functions that will perform some work to achieve the results you want.

Actually demonstrating the benefit of functional options in a case like GORM's is much easier to show in code, so let's go ahead and do that and see if we can create a friendlier API.

Note: I'm not bashing GORM, and I am in fact a big fan of the package. I'm not even certain if the entire package could be rewritten to use functional options, so please don't take any of this to imply that the package isn't great.

Recreating a subset of GORM to demonstrate functional options

Rather than rewriting GORM (which would take quite a while), we are instead going to focus on a single method - the First method. To rewrite this we aren't even going to change GORM, but are instead going to create a wrapper on top of the existing GORM package that will demonstrate my point without needing to dig into the gritty details of GORM. This will be enough code to illustrate the difference between method chaining and functional options, while also demonstrating how much easier our new version is to use as an API consumer.

The first thing we are going to need is a type defining what our options look like. Technically we don't need this, but I tend to find it makes my code easier to read, write, and understand. We are going to name this QueryOption and it will be a function that accept a *gorm.DB and returns both a *gorm.DB and an error. That way it is very clear when our query option encountered an error.

type QueryOption func(db *gorm.DB) (*gorm.DB, error)
Enter fullscreen mode Exit fullscreen mode

Next we need a way to define a QueryOption. I'm going to just cover the Where method in GORM for now, but you could cover pretty much any of it's methods the same way.

func Where(query interface{}, args ...interface{}) QueryOption {
  return func(db *gorm.DB) (*gorm.DB, error) {
    ret := db.Where(query, args)
    return ret, ret.Error
  }
}
Enter fullscreen mode Exit fullscreen mode

In this code we accept a query and args (this part is copied from GORM's source code), and then we would return a QueryOption. We then proceed to create the closure we want to return, which will use the provided arguments when calling GORM's Where method, and will capture the resulting *gorm.DB. Finally, this will return the *gorm.DB and whatever is assigned to it's Error field.

At this point the code likely doesn't look much clearer, but bear with me for a bit. Right now we are essentially rewriting GORM, and the simplicity doesn't come until we start to use the new API.

Next up is the First method. We need a way to query and we will pass these functional options (the Where method we just created) into our new First method. While we are at it, we are going to create a new DB type to wrap the *gorm.DB type so that we can define this new method.

// gorm.DB wrapper
type DB struct {
  *gorm.DB
}

func (db *DB) First(out interface{}, opts ...QueryOption) error {
  // Get the GORM DB
  gdb := db.DB
  var err error = nil
  // Apply all the options
  for _, opt := range opts {
    gdb, err = opt(gdb)
    if err != nil {
      return err
    }
  }
  // Execute the `First` method and check for errors
  if err := gdb.First(out).Error; err != nil {
    return err
  }
  return nil
}
Enter fullscreen mode Exit fullscreen mode

This code basically grabs the *gorm.DB instance, then iterates over every QueryOption provided applying it to the gorm DB and capturing the updated value and any errors that occur along the way. If an error does occur, there isn't really much point in continuing, so our First method will return the error immediately. Otherwise, our code will eventually call GORM's First method with the out variable (the destination object, similar to how GORM's First method works) and return any errors encountered.

We are now ready to see this in action and test whether this truly is friendlier.

Using our GORM wrapper with functional options

Now that we have our updated code, we need to look at an example of how to use it. We are going to be using a fictional User type to query our database, and if you want to run this locally you could do so by grabbing the final source code (linked below the code) and tweaking the database connection string.

var user User
err = db.First(&user,
  Where("email = ?", "jon@calhoun.io"),
  Where("id = ?", 2),
)
if err != nil {
  panic(err)
}
fmt.Println(user)
Enter fullscreen mode Exit fullscreen mode

To use our GORM wrapper we are going to have to use a real database. That means our code won't run on the Go Playground, but I am still going to include a link to a completed copy of the code there for you to reference - https://play.golang.org/p/3alDpVkPp6

While using our new API isn't really any less code to use, I find that this code is much easier to use and helps clarify when a user needs to check for errors. We don't need to worry about users learning about the Error field, or forgetting to capture the updated *gorm.DB instances before doing so. If there is an error at any time during execution it will be returned by the First method that we created. Neato!

Did you enjoy this article? Join my mailing list!

If you enjoyed this article, please consider joining my mailing list.

I will send you roughly one email every week letting you know about new articles or screencasts (like this one) that I am working on or have published recently. No spam. No selling your emails. Nothing shady - I'll treat your inbox like it was my own.

As a special thank you for joining, I'll also send you both screencast and ebook samples from my course, Web Development with Go.

Top comments (2)

Collapse
 
bgadrian profile image
Adrian B.G.

I like that you offer an alternative, I loved chaining in JS but I don't think they belong to a Go code.

Offtopic: I am surprised that ORM's are still used nowdays. So many restrictions, is like a framework over a framework that tells you how to do it. I hit so many problems over the years that I don't get it how is still alive. The only benefit it offers will most probably fail and I never saw a team use it (switch to other SQL database).

Especially now when you have so many data storage types accessible and services (loggers, graph databases, noSQL documents, event sourcing).
Especially in a language where you do not have Objects! from ORM

Collapse
 
foresthoffman profile image
Forest Hoffman

Well said Jon! I use the functional approach to handle mocking methods that handle API requests, in my tests. It may take more effort up front, but the long term rewards are very real.