5 minutes
Options Pattern in Go
When we are developing any application we always should try to follow DRY (Don’t repeat yourself). Doing that we’ll end up having some reusable pieces of code that makes our application more tiny and easy to maintain.
Initial Case
To do so we’ll have to deal for sure with configurable services.
For example we could setup a web server doing something like this:
server := NewServer("127.0.0.1", 80, 30, 1, false)
func NewServer(host string, port int, timeout int, maxChildren int, debugMode bool) *Server {
return &Server{
host: host,
port: port,
timeout: timeout,
maxChildren: maxChildren,
debugMode: debugMode,
}
}
This is ok as long as we want to force the service client to setup all the parameters. But if the parameters number start growing creating new servers could by a bit tedious. Let’s try to improve that.
Default Values
Now we’ll add the possibility to not send some of the parameters.
We’ll do that creating some specific constructors:
server := NewDefaultServer("127.0.0.1", 80)
server := NewDebugableServer("127.0.0.1", 80)
server := NewServerWithTimeout("127.0.0.1", 80, 30)
func NewDefaultServer(host string, port int) *Server {
return &Server{
host: host,
port: port,
timeout: 30,
maxChildren: 1,
debugMode: false,
}
}
func NewDebugableServer(host string, port int) *Server {
return &Server{
host: host,
port: port,
timeout: 30,
maxChildren: 1,
debugMode: true,
}
}
func NewServerWithTimeout(host string, port int, timeout int) *Server {
return &Server{
host: host,
port: port,
timeout: timeout,
maxChildren: 1,
debugMode: false,
}
}
Yet with this way of doing it we’ll end up having a lot of constructor functions so the application will be harder to maintain. We need something more flexible.
The Options Pattern to the Rescue
The options pattern is a flexible object provisioning strategy that uses a list of independent object modifiers.
Let’s adapt our example to this pattern:
server := NewServer(
WithHost("127.0.0.1"),
WithPort(80),
)
server := NewServer(
WithHost("127.0.0.1"),
WithPort(80),
WithDebugMode(true),
)
server := NewServer(
WithHost("127.0.0.1"),
WithPort(80),
WithTimeout(30),
)
type Option func(s *Server)
func WithHost(host string) Option {
return func(s *Server) {
s.host = host
}
}
func WithPort(port string) Option {
return func(s *Server) {
s.port = port
}
}
func WithTimeout(timeout string) Option {
return func(s *Server) {
s.timeout = timeout
}
}
func WithDebugMode(debugMode string) Option {
return func(s *Server) {
s.debugMode = debugMode
}
}
This starts looking great. With this we can give fully control to the service client to choose how they want to create a new server. But maybe the API is too open right now.
Parameter Validation
To improve security we have to ensure that an invalid server can not be even created. We have to handle errors explicitly when creating new services.
Let’s add some validations to the example:
type Option func(s *Server) error
func WithHost(host string) Option {
return func(s *Server) error {
if !IsValidHost(host) {
return errors.New("invalid host provided")
}
s.host = host
return nil
}
}
func WithPort(port string) Option {
return func(s *Server) {
if !IsValidPort(port) {
return errors.New("invalid port provided")
}
s.port = port
return nil
}
}
Handling options in the constructor
All the functions we’ve seen are adding the data to the server struct. But we are still missing a constructor that will handle all this.
func NewServer(withOptions Option...) (*Server, error) {
newServer := &Server{}
for _, option := range withOptions {
if err := option(result); err != nil {
return nil, &ServerConstructionError{err}
}
}
// Check if newServer has everything needed
// Add default parameters
return newServer, nil
}
Group parameters that one depends on the other
We need to avoid that service client ends up with an invalid state. One way of doing that is grouping parameters in the same option.
func WithMemory(allocated, limit int) Option {
return func(s *Server) error {
if allocated > limit {
return errors.New("allocated can not be greater than the limit")
}
s.allocated = allocated
s.limit = limit
return nil
}
}
Improving our constructor
Let’s refactor our solution a bit creating a helper that will handle options.
func WithOptions(options ...Option) Option {
return func(s *Server) error {
for _, option := range options {
if err := option(s); err != nil {
return err
}
}
return nil
}
}
So our constructor will be:
func NewServer(withOptions Option...) (*Server, error) {
newServer := &Server{}
if err := WithOptions(withOptions...)(newServer); err != nil {
return nil, &ServerConstructionError{err}
}
// Check if newServer has everything needed
// Add default parameters
return newServer, nil
}
All these changes we’ll allow us to do some cool stuff like having default constructors or more semantic ones. We can even group options.
func WithDefaultOptions() Option {
return WithOptions(/*...*/)
}
func WithDebugOptions() Option {
// Check if we are in production
return WithOptions(/*...*/)
}
// visual grouping
WithOptions(First(), Second(), Third())
Complex configurations
We could pass some options with adapters:
server := NewServer(
WithHost("127.0.0.1"),
WithPort(80),
WithCaching(RedisCacheAdapter(&RedisSettings{
/*...*/
}))
)
Maybe some grouped options are complex too so we want to have an struct to group them in a clearer way:
server := NewServer(
WithHost("127.0.0.1"),
WithPort(80),
WithTimeouts(&TimeoutSettings{
Ingress: time.Second * 10,
Egress: time.Second * 10,
IdleSessions: time.Minute * 15,
})
)
Also, callbacks of anonymous functions can be configured too:
server := NewServer(
WithHost("127.0.0.1"),
WithPort(80),
WithErrorHandler(func(err error) error {
/*...*/
})
)