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 toConfig
. - Declaring a
ConfigWithLogLevel
function that returnsConfigOption
. - Setting up a constructor to return a new pointer to
*Config
and accepting a variadic number ofConfigOption
s.
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!