Functional options are a powerful Golang pattern that can be used to write small and flexible APIs. Here's some Lessons Andrea learnt the hard way of how and when to use them.

10 years of functional options and key lessons Learned along the way


The below is a guest post by Andrea Medda Campus.


Functional options are a powerful Golang pattern that can be used to write small and flexible APIs. I first learnt about them from this great blog by Dave Cheney.

I’ve been working with Go in production for about 10 years now and I’ve been using (and misusing!) functional options extensively.

In this blog post, I’m going to share what I learned about this pattern, the Dos and the Do nots as well as key lessons learned along the way.

I’m applying these concepts building smithy, an open-core workflow engine for orchestrating and normalising any security tool to give more time back to developers. You can check out these learnings being applied while building our new SDK.

Let’s get to it!

What are functional options anyway

functional options are a pattern that is used to apply N >= 0 customisations to underlying concrete instances of structs.

This is done by relying on pointers to such instances and variadic arguments.

Example

Considering a scenario where we want to create a new logger in our application with a default log level error but make it overridable in different environments (for example in staging), we can do so by:

  • Defining a struct Config to hold our configuration.
  • Creating a type ConfigOption which takes a pointer to Config.
  • Declaring a ConfigWithLogLevel function that returns ConfigOption.
  • Setting up a constructor to return a new pointer to *Config and accepting a variadic number of ConfigOptions.

In practice, this translates to:

package config

type (
    // Config holds the application's configuration.
    Config struct {
        // LogLevel sets the application's logger log level.
        LogLevel string
    }
    
    // ConfigOption allows to customise a *Config.
    ConfigOption func(*Config)
)

// ConfigWithLogLevel allows overriding the default log level.
func ConfigWithLogLevel(level string) ConfigOption {
    return func(conf *Config) {
        conf.LogLevel = level
    }
}

// New returns a new Config with overridable defaults.
func New(opts ...ConfigOption) *Config {
    // setting defaults
    conf := &Config{
        LogLevel: "error",
    }
    
    // applying options, if passed.
    for _, opt := range opts {
        opt(conf)
    }
    
    // returning a customised config.
    return conf
}

That can then be called like follows:

package main

import (
    "log"

    "github.com/bytesizego/functional-opts/config"
)

func main() {
    conf := config.New()
    log.Println(conf.LogLevel) // "error"
    
    conf = config.New(config.ConfigWithLogLevel("debug"))
    log.Println(conf.LogLevel) // "debug"
}

you can find a working snippet that uses this code here.

How does this work?

Our option type ConfigOption takes in an input of a pointer to the struct that we want to customize.

When we define a function that returns a ConfigOption like ConfigWithLogLevel, we can take any desired input and digest it in some way, then use it to customise the value of the passed pointer to Config. This has to be a pointer so that applied customizations are actually persisted.

By accepting a variadic number of arguments in the constructor New, we make sure to allow passing 0 or N arguments, enabling for flexible customisations at run time.

The constructor is designed to return a usable instance of Config with its defaults.

To put it all together, the constructor “applies” the passed options in the supplied order, allowing for overwriting the defaults.

When should they be used?

sensible defaults

A common use case for functional options is to override sensible defaults at run time. This is very useful to customise non-critical configuration in an application.

Example

If we re-use the previous example, we can think of this real world use case were we want to customise the log level based on an environment variable:

package main

import (
    "os"
    
    "github.com/bytesizego/functional-opts/config"
)

func main() {
    logLevel := os.Getenv("LOG_LEVEL")
    
    conf := config.New(config.ConfigWithLogLevel(logLevel))
}

The nice thing about this use is that we know that we have a default for LogLevel anyway and we can safely override this at run time, if needed.

For instance, we could set the environment variable to LOG_LEVEL=debug and restart our application to debug a specific bit that was skipped by the previous log level without making any code changes.

Testing

Another common use of this pattern is testing.

Often, one would like to pass a specific mock instance for an argument when testing, but, at the same time, rely on a specific default for when an application is actually running in the production environment.

This can be achieved in multiple ways but functional options make this a breeze!

Example

Consider a case where we have some business logic that has to act on a the current time, like logging when we need to feed our beloved cat:

package main

import (
	"log"
	"time"
)

// CatFeeder contains the business logic.
type CatFeeder struct{}

// ShouldFeed tells us whether we should feed our cat or not. We should do this if we're between 08:00 and 08:30.
func (cf *CatFeeder) ShouldFeed() bool {
	var (
		now           = time.Now()
		currentHour   = now.Hour()
		currentMinute = now.Minute()
	)

	return currentHour == 8 && currentMinute >= 0 && currentMinute < 30
}

func main() {
	cf := &CatFeeder{}
	if cf.ShouldFeed() {
		log.Println("it's time to feed the cat!")
	}
}

We can use functional options to test this but make sure to use a specific default current time when running in production.

We can refactor our sample code to accept a customisable Clock so that we can set a specific current time to test that it’s actually behaving as it should:

package feeder

import "time"

type (
	// Clocker defines a clock behaviour.
	Clocker interface {
		Now() time.Time
	}

	// RealClock returns the current real time.
	RealClock struct{}

	// FakeClock returns a fake customisable time.
	FakeClock struct {
		FakeTime time.Time
	}

	// CatFeeder contains the business logic.
	CatFeeder struct {
		Clock Clocker
	}

	// CatFeederOption allows to customise a pointer to CatFeeder.
	CatFeederOption func(*CatFeeder)
)

// Now satisfies Clocker returning the current time.
func (rc RealClock) Now() time.Time {
	return time.Now()
}

// Now satisfies Clocker returning a fake time.
func (fc FakeClock) Now() time.Time {
	return fc.FakeTime
}

// CatFeederWithClock allows to overwrite the default clock.
func CatFeederWithClock(c Clocker) CatFeederOption {
	return func(cf *CatFeeder) {
		cf.Clock = c
	}
}

// NewCatFeeder returns a new cat feeder.
func NewCatFeeder(opts ...CatFeederOption) *CatFeeder {
	cf := &CatFeeder{
		Clock: RealClock{},
	}

	for _, opt := range opts {
		opt(cf)
	}

	return cf
}

// ShouldFeed tells us whether we should feed our cat or not. We should do this if we're between 08:00 and 08:30.
func (cf *CatFeeder) ShouldFeed() bool {
	var (
		now           = cf.Clock.Now()
		currentHour   = now.Hour()
		currentMinute = now.Minute()
	)

	return currentHour == 8 && currentMinute >= 0 && currentMinute < 30
}

And then we can test it:

package feeder

import (
	"testing"
	"time"
)

func TestCatFeeder_ShouldFeed(t *testing.T) {
	now := time.Now()

	t.Run("it should return true when it's 08:29", func(t *testing.T) {
		var (
			fakeTime = time.Date(now.Year(), now.Month(), now.Day(), 8, 29, 0, 0, now.Location())
			clock    = FakeClock{
				FakeTime: fakeTime,
			}
			cf = NewCatFeeder(CatFeederWithClock(clock))
		)

		if !cf.ShouldFeed() {
			t.Fatal("expected to be told to feed our cat but I wasn't")
		}
	})
	t.Run("it should return false when it's 08:45", func(t *testing.T) {
		var (
			fakeTime = time.Date(now.Year(), now.Month(), now.Day(), 8, 45, 0, 0, now.Location())
			clock    = FakeClock{
				FakeTime: fakeTime,
			}
			cf = NewCatFeeder(CatFeederWithClock(clock))
		)

		if cf.ShouldFeed() {
			t.Fatal("expected to not be told to feed our cat but I was")
		}
	})
}

And finally we can refactor our main:

package main

import (
	"log"

	"github.com/bytesizego/functional-opts/feeder"
)

func main() {
	cf := feeder.NewCatFeeder()
	if cf.ShouldFeed() {
		log.Println("it's time to feed the cat!")
	}
}

This example can be used in a variety of situations; not just for testing.

Public API

Functional options can be used to maintain compact but extensible public API.

Unfortunately, it’s very common to see constructors that take a huge amount of arguments and then leading to issues with both maintainability, readability and breaking changes.

This in combination with public API, like in an SDK for example, could be really an expensive burden on your productivity.

Functional options to the rescue!

We can apply what we learned earlier to refactor a bloated constructor for an http client into a small but efficient constructor one.

Example

Consider:

// UserClient implements a client for the users' service.
type UserClient struct {
    httpClient *http.Client
    
    maxRetries uint
    timeout time.Duration
    baseURL string
    authUsername string
    authPassword string
}

// NewUserClient returns a new user client.
func NewUserClient(
    maxRetries uint,
    timeout time.Duration,
    baseURL string,
    authUsername string,
    authPassword string,
) *UserClient {
    return &UserClient{
        httpClient: &http.Client{
            Timeout: timeout,
        },
        maxRetries:   maxRetries,
        timeout:      timeout,
        baseURL:      baseURL,
        authUsername: authUsername,
        authPassword: authPassword,
    }
}

Now imagine passing even more arguments and having to maintain a public API associated with this client. It could become hellish.

You might want to use an helper UserClientConfig struct but that’s also tricky to maintain as each field could be updated or read in a way that you don’t want to.

Let’s leverage functional options instead:

// UserClient implements a client for the users' service.
type (
    UserClient struct {
        httpClient *http.Client

        maxRetries uint
        timeout time.Duration
        baseURL string
        authUsername string
        authPassword string
    }
    
    // UserClientOption allows customising the underlying UserClient.
    UserClientOption func(*UserClient)
)

// WithMaxRetries allows customising the max retries.
func WithMaxRetries(retries uint) UserClientOption {
    return func(c *UserClient) {
        c.maxRetries = retries
    }
}

// WithTimeout allows customising the timeout.
func WithTimeout(timeout time.Duration) UserClientOption {
    return func(c *UserClient) {
        c.timeout = timeout
        // Also update the HTTP client's timeout
        c.httpClient.Timeout = timeout
    }
}

// WithBaseURL allows customising the base url.
func WithBaseURL(url string) UserClientOption {
    return func(c *UserClient) {
        c.baseURL = url
    }
}

// WithAuth allows customising the aith.
func WithAuth(username, password string) UserClientOption {
    return func(c *UserClient) {
        c.authUsername = username
        c.authPassword = password
    }
}

// WithHTTPClient allows customising the http client.
func WithHTTPClient(client *http.Client) UserClientOption {
    return func(c *UserClient) {
        c.httpClient = client
    }
}


// NewUserClient returns a new pointer to a UserClient.
func NewUserClient(opts ...UserClientOption) *UserClient {
    // Set up defaults
    client := &UserClient{
        httpClient: &http.Client{
            Timeout: 30 * time.Second, // Default timeout
        },
        maxRetries:   3,              // Default to 3 retries
        timeout:      30 * time.Second,
        baseURL:      "http://localhost:8080", // Default base URL
        authUsername: "",             // No auth by default
        authPassword: "",
    }

    // Apply all options
    for _, opt := range opts {
        opt(client)
    }

    return client
}

Now, this can be called with NewUserClient() for local development or for default behaviour and by passing any options to customise its behaviour for different environments or testing.

This also allows us to:

  • Make sure that we have a decent default initialisation to cover our base cases and avoid panics or misconfiguration.
  • Allow for customisation.
  • Extend with new options so that we don’t have to change our public API or break it.
  • Keep the code maintainable, readable and testable.

Do and Do not

Now that we covered some common scenarios, we can talk about some Do and Do nots with this pattern. We can apply the following principle to our first Config example to end up with an even better result.

Naming conventions for options

Do

  • Name your options in a way that makes it obvious to understand what they are customising and on what.
  • Always use the naming convention NameOfStructOption to avoid collisions and make things clear.
  • Use the substring With when defining an option. This is telling the reader that some type of customisation is happening on the passed struct.

This is good:

type (
    Config struct {
        LogLevel string
    }
    
    ConfigOption func(*Config)
)

func ConfigWithLogLevel(level string) ConfigOption {
    return func(conf *Config) {
        conf.LogLevel = level
    }
}

Do not

  • Define multiple options that customise the same fields. This is confusing.

This is bad:

type (
    Config struct {
        LogLevel string
    }
    
    Option func(*Config)
)

func LogLevel(level string) Option {
    return func(conf *Config) {
        conf.LogLevel = level
    }
}

func LogLevelDebug() Option {
    return func(conf *Config) {
        conf.LogLevel = "debug"
    }
}

Error handling

Do

  • You should actually validate the arguments passed to your constructor and options to avoid undesired behaviours.
  • You should handle options errors.

This is good:

type (
    Config struct {
        LogLevel string
    }
    
    ConfigOption func(*Config) error
)

func isLevelValid(level string) bool {
    return level == "warning" || level == "info" || level == "error" || level == "debug"
}

func ConfigWithLogLevel(level string) ConfigOption {
    return func(conf *Config) error {
        if !isLevelValid(level) {
            return fmt.Errorf("invalid level %s provided", level)
        }
        conf.LogLevel = level
        return nil
    }
}

func New(opts ...ConfigOption) (*Config, error) {
    conf := &Config{
        LogLevel: "error",
    }
    
    for _, opt := range opts {
        if err := opt(conf); err != nil {
            return fmt.Errorf("could not apply option: %w", err)
        }
    }
    
    return conf, nil
}

Do not

  • Rely on the input values supplied on your constructor and options without validation

Bad examples can be found in the previous sections of the post.

Unexporting is your friend

Do

  • Unless you have a good reason not to, always leverage unexported struct fields and leverage constructors to initialise them to implement this pattern. This makes sure to give users only one way to initialise and customise a struct with a predictable outcome. Not following the paved road would be challenging and time wasting for users.
  • Consider unexporting option types and internal only options as well if they are not meant to be used by users.

This is good:

type (
    Config struct {
        logLevel string
    }
    
    configOption func(*Config) error
)

func isLevelValid(level string) bool {
    return level == "warning" || level == "info" || level == "error" || level == "debug"
}

func ConfigWithLogLevel(level string) configOption {
    return func(conf *Config) error {
        if !isLevelValid(level) {
            return fmt.Errorf("invalid level %s provided", level)
        }
        conf.logLevel = level
        return nil
    }
}

func New(opts ...ConfigOption) (*Config, error) {
    conf := &Config{
        logLevel: "error",
    }
    
    for _, opt := range opts {
        if err := opt(conf); err != nil {
            return fmt.Errorf("could not apply option: %w", err)
        }
    }
    
    return conf, nil
}

And in cases where the user doesn’t need access to a struct’s fields, you can also unexport the struct itself so you lock it even further.

Do not

  • Export every type, fields and functions.
  • Not limit how users can get a new instance of your types.

Bad examples can be found in the previous sections of the post.

Final takeaways

No defaults are harmful

When leveraging functional options, make sure to set good defaults in your constructor. These will make sure that your application behaves in an acceptable way even when not customised.

This makes a very big different when it comes to running applications locally or on CI.

Self-contained options

Functional options shouldn’t depend on other functional options or the order in which these are applied in a constructor.

This prevents complicated logic and the chance to have harmful bugs in production.

Balance between required arguments and optional ones

It’s always tricky to balance this bit out.

The rule of thumb is to always pass required arguments only and everything else as an option. A required argument should be something that your instance needs to function in all cases.

For example, you could require a specific string representing an environment like dev so that you can then take other decisions in your constructor.

Avoid using too many required arguments though. You can leverage helper configuration structs to help you with this bit, so that you always have one argument only which is also customisable and its extension doesn’t lead to breaking changes.

Builder pattern should be avoided

The builder pattern can be used to achieve a lot of the same things that functional options bring you, why should it be avoided?

From my experience, the builder pattern can be way too verbose and each building step can be called on an instance of a struct initialised outside the constructor. This doesn’t protect us from misusing our API.

Using functional options in combination with a good constructor that returns an initialised instance of a struct with good defaults for private fields and allowing for customisation is much more ergonomic.

I’ve also seen the builder pattern being misused by making assumptions on the order that fields have to be built.

The functional options pattern assumes that options can be passed in any order and it shouldn’t really matter.

Last but not least, the builder pattern is more error prone as it relies on the Build() method to be called when the instance is ready to be built.

With the functional options pattern we don’t have this problem as it’s taken care of by the constructor.

Conclusions

We learned what functional options are and how to leverage them to keep our API small and testable.

We also learned how to defend ourselves from misusing such API with a few improvements to the initial pattern.

Finally, we compared the functional options pattern with the builder pattern and we know how they differ.

Have fun building your next API!