golang

Effective Options

by Thanh Pham
Mon 01 Jan 0001 / 12 minutes 25 seconds read
unsplash/Victoriano Izquierdo
unsplash/Victoriano Izquierdo
Summary.Organizing options in API is very important. We can use setters or builders, but options as a function and options as an interface are better approaches. They are much more beautiful , flexible and easier to use for the end users.

When building a library, we normally try to think how the other developers would use our code and try to provide as many APIs as possible. But sometimes, exposing too many APIs would be very confusing. Or even worse, we failed to provide what our users need, but exposing too many APIs that they don't even need. Hence organizing what we expose and how they should be exposed is really important.

Normally, what we expose to the end users are either constructors or methods. Some parameters are required and some are optional. Since we want to expose as many APIs as we can for the end users, we will end up having a lot of variants of the same API.

Consider the struct below, where host is required but others are optional:

type Cache struct {
	host         string
	username     string
	password     string
	readTimeout  time.Duration
	writeTimeout time.Duration
}

The easiest way is to expose everything in 1 single constructor, but it's very confusing for users since it's hard to know which ones are required and which ones are optional. Hence we might end up having multiple versions of the constructor as below:

New(host string) *Cache
New(host string, readTimeout time.Duration) *Cache
New(host, username, password string) *Cache
New(host, username, password string, readTimeout, writeTimeout time.Duration) *Cache
....

Unfortunately, Go doesn't support method overloading, hence we have to give each of them a different names:

func New(host string) *Cache
func NewWithReadTimeout(host string, readTimeout time.Duration) *Cache
func NewWithUserPass(host, username, password string) *Cache
func NewWithUserPassAndTimeouts(host, username, password string, readTimeout, writeTimeout time.Duration) *Cache
....

Imagine having 20 optional parameters, naming them is really a nightmare and we might fall into some really bad names. We definitely need a better approach, and some one might suggest using setters.

Setters

With setters, we can improve it a little bit to distinguish the required and optional parameters by putting the required parameters in the constructor New and other optional parameters can be provided by setters. The below block of code show how nice the code is with the setters approach:

func New(host string) *Cache
func (c *Cache) SetUserPass(user, pass string)
func (c *Cache) SetReadTimeout(d time.Duration)
func (c *Cache) SetWriteTimeout(d time.Duration)
Playground

The code looks just beautiful! We can see setters can resolve the problem we have previously with our constructors. But how can we apply it to functions and methods? Consider the below method of the Cache service, where the ttl is optional.

func (c *Cache) Set(k string, v interface{}, ttl time.Duration) error

The API looks OK but there is just one thing ugly. That is even if you don't want to use TTL, you still need to pass a value into the function. What bothers the developer is that he doesn't know whether he should pass -1, or 0 in case he doesn't want his data to be expired. This can be addressed with good documentation or even by adding 2 version of the method:

Set(k string, v interface{}) error
SetWithTTL(k string, v interface{}, ttl time.Duration) error

But what if we have many options besides TTL? For example, Set command can be able to use its own token for authentication? This solution would lead us to defining a lot of variants of the set commands like what we faced with our constructor.

One solution to address the above problem is provide them a parameter struct where the developer can set the parameters he needs. Apply the same approach as what we have done for constructors:

type SetRequest struct {
	k   string
	v   interface{}
	ttl time.Duration
	tok string
}

func NewSetRequest(k string, v interface{}) *SetRequest {
	return &SetRequest{
		k: k,
		v: v,
	}
}

func (r *SetRequest) SetTTL(ttl time.Duration) {
	r.ttl = ttl
}

func (r *SetRequest) SetToken(tok string) {
	r.tok = tok
}

func (c *Cache) Set(r *SetRequest) error {
	// TODO implement me.
	// if r.ttl > 0 {...}
	// if r.tok != "" {...}
	return nil
}

And this is how our end users use it:

c := New("localhost:8000")
c.SetUserPass("user1", "pass")
c.SetReadTimeout(5 * time.Second)

// set with TTL only
r := NewSetRequest("name", "Jack")
r.SetTTL(5 * time.Second)
_ = c.Set(r)

// set with ttl and token
r1 := NewSetRequest("age", 22)
r1.SetTTL(5 * time.Second)
r1.SetToken("abcd")
_ = c.Set(r1)
Playground

The above code is OK but not really beautiful, since we have to create a new SetRequest and set its properties in separate lines which sometimes I feel so uncomfortable to do. We can do it even better, with builders.

Builders

The idea of builders is quite simple, a setter of a struct return itself:

func NewSetRequest(k string, v interface{}) *SetRequest {
	return &SetRequest{
		k: k,
		v: v,
	}
}

func (r *SetRequest) SetTTL(ttl time.Duration) *SetRequest {
	r.ttl = ttl
	return r
}

func (r *SetRequest) SetToken(tok string) *SetRequest {
	r.tok = tok
	return r
}

The same thing can be applied to constructor. And here is how we use it:

c := New("localhost:8000").SetUserPass("user1", "pass").SetReadTimeout(5 * time.Second)

// set with TTL only
r := NewSetRequest("name", "Jack").SetTTL(5 * time.Second)
_ = c.Set(r)

// set with ttl and token
r1 := NewSetRequest("age", 22).SetTTL(5 * time.Second).SetToken("abcd")
_ = c.Set(r1)
Playground

The above code looks just beautiful. But there is still one thing I don't really like: that is the prefix "Set" before every single setter. Sometimes I just remove the prefix "Set" from the builders' names, but someone will argue that it violates the  name convention. To be honest, in case of a builder, sometimes I don't care and just remove the prefix from the names to make it more beautiful when using:

c := New("localhost:8000").UserPass("user1", "pass").ReadTimeout(5 * time.Second)

// set with TTL only
r := NewSetRequest("name", "Jack").TTL(5 * time.Second)
_ = c.Set(r)

// set with ttl and token
r1 := NewSetRequest("age", 22).TTL(5 * time.Second).Token("abcd")
_ = c.Set(r1)
Playground

It looks better but to be honest, I don't really like constructing request param and setting everything like that. We can do it better with options as a function.

Options As A Function

The idea of options as a function is pretty simple, options are setting up via a function. We force people to provide required parameters directly in the constructor or directly in the method/function, and optionally provide the advanced options using variadic parameters.

Instead of using setters, we will use functions for setting up the struct, here is how we do with the constructor, the same can be applied for methods/functions as well:

type Option func(*Cache)

func New(host string, opts ...Option) *Cache {
	c := &Cache{host: host}
	for _, opt := range opts {
		opt(c)
	}
	return c
}

func UserPass(user, pass string) Option {
	return func(c *Cache) {
		c.username = user
		c.password = pass
	}
}

func ReadTimeout(d time.Duration) Option {
	return func(c *Cache) {
		c.readTimeout = d
	}
}

func (c *Cache) WriteTimeout(d time.Duration) Option {
	return func(c *Cache) {
		c.writeTimeout = d
	}
}
Playground

And here is how we use it:

c := New("localhost:8000", UserPass("user1", "pass"), ReadTimeout(5*time.Second))
_ = c.Set("name", "Jack")
_ = c.Set("name", "Jack", TTL(5*time.Second))
_ = c.Set("age", 22, TTL(5*time.Second), Token("abcd"))

Just simply beautiful! No more confusing, required parameters are forced to provide, optional parameters are provided as needed.

I think this approach is simple enough to implement and I'm currently using it for production code. But there is still another interesting approach, that is to provide options as an interface.

Options As An Interface

The idea of options as an interface is that everything can be considered as an option if it implements our option interface, so instead of using function, we will use interface as following:

type Option interface {
	apply(*Cache)
}

type optionFunc func(*Cache)

func (f optionFunc) apply(c *Cache) {
	f(c)
}

func New(host string, opts ...Option) *Cache {
	c := &Cache{host: host}
	for _, opt := range opts {
		opt.apply(c)
	}
	return c
}

func UserPass(user, pass string) Option {
	return optionFunc(func(c *Cache) {
		c.username = user
		c.password = pass
	})
}

func ReadTimeout(d time.Duration) Option {
	return optionFunc(func(c *Cache) {
		c.readTimeout = d
	})
}

func (c *Cache) WriteTimeout(d time.Duration) Option {
	return optionFunc(func(c *Cache) {
		c.writeTimeout = d
	})
}
Playground

This approach produces the same APIs for the end users as the previous one:

c := New("localhost:8000", UserPass("user1", "pass"), ReadTimeout(5*time.Second))
_ = c.Set("name", "Jack")
_ = c.Set("name", "Jack", TTL(5*time.Second))
_ = c.Set("age", 22, TTL(5*time.Second), Token("abcd"))
Playground

Although this approach looks a little bit more complicated than the options as a function approach, but it uses an interface hence it will be more flexible for end users to extend our library to have more customized options as their needs.

Summary

All the provided above approaches proved that everyone can write beautiful APIs with just a little effort. Some of you might like builders, but I would suggest you use options as a function or options as an interface since they are more beautiful and more flexible than the previous ones.

For more examples, look at packages cache, broker or server of my micro toolkit.

Next In