Building Flexible APIs with the Options Pattern in Go

May 9, 2023
Building Flexible APIs with the Options Pattern in Go

Functional Options Pattern is a way of creating expressive and flexible APIs, especially when dealing with large number of optional arguments. Unlike other languages, Go does not support default values for function arguments. By using this pattern, you can create APIs that can be customised to meet a wide range of use cases, without sacrificing readability or maintainability. This pattern also make it easier to adapt to requirements that change over time, without introducing breaking changes.

The Challenge

In this post we’ll be working on implementing a Client for our company’s public API, which initially it is a small and simple structure.

    
  

Because all fields of the Client are unexported there’s also a constructor that can be used outside of the package where the structure is defined.

ℹ️ Constructors in golang Golang does not provide any built in constructors, but you can use constructor functions instead. They are typically prefixed with New (or simply named New ) and can be used to set default values or perform other initialisation tasks.

The constructor takes only one argument at the moment, but it’s size could quickly grow after we start adding new features.

    
  

After introducing options for configuring proxy, number of retries and timeout, not only did the constructor’s argument list become longer, but we’ve also introduced breaking changes for those who are already using our code.

From the user’s perspective, to create a minimal Client (only with authToken ) we still need to provide zero values for the rest of arguments to the constructor.

    
  

The naive solution to improve that would be to introduce multiple constructors for different purposes.

    
  

This approach might work if the number of options doesn’t grow or change too often. Otherwise, it will add lots of code that needs to be maintained, especially if you consider all the permutations of the optional arguments.

It’s also not the best thing from the user’s perspective. Imagine a name of a constructor for creating a client with proxy and timeout or a client with proxy and timeout and… I hope you understand what I’m getting at.

As an alternative approach, we can consider using a configuration struct (or options struct) instead.

    
  

If we decide to extend our client once again, users won’t be faced with a breaking change in the constructor. The only changes will be in the Options structure (and our client of course).

The other good thing is that all of our optional arguments are grouped together and we don’t need to specify all of them, just the ones we actually need.

    
  

While it increased the usability it still has some big drawbacks. Now, we need to maintain two structures instead of one and make sure they are aligned at all times. Moreover, users are still required to pass some value as the second argument, even though they don’t want to configure any optional arguments.

    
  

Right now, the client won’t retry the call if number of retries is not specified during creation. We want to change this behavior and retry 3 times by default. Let’s modify the code to treat it as an optional argument with a default value.

    
  

It works as expected, but now we made it impossible for the user to configure the client to fail without any retries.

    
  

The obvious solution is adding a pointer, but from the usage perspective it just feels wrong…

    
  

Implementing the Functional Options

Based on what was already presented, we can form requirements for the constructor:

  • A single constructor for all use cases
  • Don’t require anything but what’s really necessary
  • Allow default values
  • Work well with zero values
  • Be flexible and easily extendible

Functional options pattern checks all of the above. Let’s take a look how to implement it for our Client .

    
  

In the constructor we substituted the Options structure with a variadic argument which is a function. The Client is being configured not based on the attributes provided to the function (explicit arguments or with Options structure), but with functions that manipulate the Config itself. It’s possible, because the client variable is passed to those functions as a pointer.

If no options are provided, the loop simply won’t execute, leaving the client unchanged with the default values.

We also added some functions that can be used for configuring the client. Those functions take arguments that are required to update the client and return functions with the same signature as we introduced in the constructor.

✅  Try to make functional options verbose This pattern does not limit option names to ones like WithXYZ . In fact it’s good to name them in expressive and understandable way. We want our code to be easy and pleasant to use!

    
  

If users want to configure the client beyond the defaults, the only thing they need to do is to use the exposed functions in the constructor. It’s worth mentioning that the order of options can matter . If you provide the same option twice, the latter will be preserved.

The variadic argument also allows us to skip everything but the required argument, just as we wanted!

While we were using the Options structure, we introduced the default value for the maxRetries . The same can be done using functional options:

    
  

Adding default values using this pattern couldn’t be simpler. It’s only a matter of setting the value in our initial structure. What’s even more important, we can now use zero values to overwrite the defaults. This satisfies another requirement.

The last thing to verify is whether functional options give us the flexibility we want. This pattern allows us to do much more than just setting provided values.

    
  

In the snippet above, shouldRetry variable was added to the client. As there is no need for the users to be aware of it, we don’t need to add any extra option for setting it. It’s solely for developers working on the client. The value of this variable is being set behind the scenes - first as a default value true and then it can be switched to false based on the value provided to the WithRetries function.

It’s only one of the potential ways to use this pattern’s flexibility. Adding new options or updating client internals won’t break users’ code. It’s also easier to depreciate option that is not valid anymore.

So, What are My Options?

One of the biggest concerns and reasons why people dislike this pattern is weak discoverability. In order to find all available options you need to search the package for them. You can also use pkg.go.dev or go doc tool.

Let’s check what it generates for our implementation:

    
  

That isn’t too helpful, is it? Luckily, there is a simple way of making go doc group the options together.

    
  

The main change involves introducing a new named type for the option function that we have introduced previously. Then we need to update the constructor’s and options’ signatures to use it instead of a plain func(*Client) .

Those changes result in such output from go doc :

    
  

Adding a function type for our option allowed us to massively improve the documentation. Now, all available options are listed together and we can quickly find the ones we’re interested in.

Everything Comes at a Cost

Functional options allowed us to create a flexible and expressive API. We gained readability and usability, but did we loose any speed? I run some benchmarks to compare the approach with options structure to functional options pattern.

    
  

The results of this experiment are quite interesting. As you can see, options structure took a bit less time .

Then, I started wondering if there is any correlation between the number of functional options and time required to execute the function.

    
  

Benchmarks clearly show that there is a time difference depending on how many variadic arguments are provided. Execution time grows together with the number of arguments provided to the constructor.

The most interesting part is that using options structure was negligibly slower than using no functional options.

Does it mean that functional options are worse? I would not put it like that. We gained a lot by introducing this pattern and users are most likely to use it only once in their code - during the spin up. Although, if you are working on a performance-critical application it might not be the best choice!

Conclusion

Although functional options pattern is not a silver bullet it can be an extremely powerful and effective pattern when used correctly. As usually, everything is down to the preference and the particular context. You should also remember, that it might be a little bit slower than other patters, but the overhead is still only in ns .

One of the next posts will cover a more advanced implementation of this pattern, so stay tuned! 🥳

GoDeveloper Experience