Variadic Configuration Functions in Go

Chris Reeves
SOON_ London
Published in
5 min readAug 25, 2017

At SOON_we regularly use Go for writing and interfacing with RESTy HTTP APIs. We run these APIs in different ways depending on the environment we are in (host names, ports, log levels etc) so we need easily configurable types.

For example, if we need to write a wrapper for MyService and the host location of the service must be configurable, a first attempt might look something like this:

type MyService struct {
Host string
}
func (svc *MyService) GetFoos() ([]Foo, error) { ... }

Which would then be executed like so:

func main() {
svc := &MyService{Host: "localhost:5000"}
foos, err := svc.GetFoos()
}

Ultimately, this is great, however, there are some notable downsides:

  • Firstly, we need to export the Host attribute on our type, which is not in itself a negative but as our implementation grows, we may need to export more and more attributes so the user can configure them as they need. This in turn could lead to errors if not handled correctly by the end user. Also, your documentation has to be up to scratch to ensure the user is well aware of what needs to be defined and how.
  • Secondly, by default, variables not explicitly defined with a value will get a zero value for strings that have an empty "". If we instantiated our type without explicitly setting Host then it’s likely requests to our API would fail, unless we had a check in place for this. As our implementation grows we need to add more conditions to our code to ensure correct sane defaults are applied in the instances where attributes on our type held zero values (or nil’s if we want pointers), which can lead to a lot of tedious boiler plate code.

Whilst these issues are not hard to overcome, we can assist our users by introducing a constructor method that creates a new instance of our type that’s ready to use:

type MyService {
host string
}
func New(host string) *MyService {
return &MyService{host: host}
}

In the above, we have not exported host to ensure a user cannot explicitly set it. Instead, a host string would need to be provided to the constructor, which in turn sets the host value and returns our type. It is now clear to the user that when constructing the type, we need a host. One drawback to this approach is how many arguments our New method needs to make our implementation grow. For example, we could configure 10 different attributes but none of them are required, meaning this solution would end up looking rather ugly and painful for our users to implement since we would need to provide default values for which a user could pass in as arguments for attributes they don’t want to configure.

A common way to combat this is to define a Config type that would be a single argument you could pass to the constructor:

type Config struct {
Host string
}
type MyService {
host string
}
func New(config Config) *MyService {
return &MyService{host: config.Host}
}

This allows our implementation to grow into as many configuration options as are needed but which still requires code to ensure the user has set all the correct values and assigned sane defaults where they have not. We are able to do this in our constructor but that might result in it being crufty and potentially prone to errors. Ideally, we need a way for a user to pass in as many configuration options as they want to our constructor and have default sane values set for attributes they don’t.

In Go, a function can take any number of trailing arguments of a specific type, these are called Variadic functions:

func sum(nums ...int) int { ... }
sum(1, 2) // 3
sum(1, 2, 3) // 6
sum(1) // 1

See https://gobyexample.com/variadic-functions

Functions in Go are also first class so we can declare them as types and pass them around like normal variables. This combined with Variadic functions gives us some powerful tools to help resolve the configuration problem.

Firstly, we declare a new type called Option, which will be a function that takes a single pointer value to a MyService instance we want to configure but returns nothing.

type MyService struct {
Host string
Logger Logger // Logger interface
}
type Option func(svc *MyService)

We can now create a constructor for our MyService type that takes any number of the Option type as arguments. We can instantiate a new MyService with some sane default values and then range over the Option functions, calling them and passing a pointer to our MyService instance (since our Option functions will need to modify the MyService instance).

func New(opts ...Option) MyService {
svc := MyService{
Host: "localhost:1337",
Logger: log.New(os.Stdout, "MyService:", log.Lshortfile),
}
for _, opt := range opts {
opt(&svc)
}
return svc
}

On top of this, we can also declare some helper Option functions for the end user to help with configuration:

func WithHost(host string) Option {
return func(svc *MyService) {
svc.Host = host
}
}
func WithLogger(logger Logger) Option {
return func(svc *MyService) {
svc.Logger = logger
}
}

These two functions also return functions, which match the Option type, so we can call these ‘helper functions’ directly in the MyService constructor passing in our configuration values:

svc1 := New(WithHost(":5000"), WithLogger(&MyCustomLogger{}))
svc2 := New(WithHost(":5000"))
svc3 := New(WithLogger(&MyCustomLogger{}))
svc4 := New()

The most impressive aspect to this is that the user can pass in as many config options as they desire (or not) and MyService will always get sane defaults for attributes not configured, with no additional conditional logic for it.

The end user could also write their own Option function if they wanted functionality we don’t provide, so perhaps to set Host and Logger all at once:

func WithHostAndLogger(host string, logger myservice.Logger) myservice.Option {
return myservice.Option(svc *MyService) {
svc.Host = host
svc.Logger = logger
}
}
...svc := myservice.New(WithHostAndLogger(":8000", &MyLocalLogger{}))

In conclusion, Variadic functions are a great way to provide configuration options easily to the end user. They can be documented via godoc as we explicitly declare those functions, constructors return types with sane default values and the user can override them as they see fit.

--

--

Chris Reeves
SOON_ London

Platform Engineer @wearebanked • Go/DevOps • Author http://github.com/krak3n/fido • Views my own • He/Him • We are all born naked & all the rest is Drag 🏳️‍🌈