Functional Options Pattern in Go

Functional Options Pattern in Go

1. What is Functional Options Pattern in Go?

Functional Options Pattern also called just Options Pattern, is a creational design pattern that lets you build a complex struct using a variadic constructor that accepts zero or more functions as arguments. We refer to those functions as options, thus the pattern name.

The Functional Options pattern is a design pattern commonly used in Go to provide flexible and configurable APIs. It allows clients to customize the behavior of a function or a struct by providing optional parameters in the form of function arguments.

Functional options take the form of extra arguments to a function, that extend or modify its behavior. Here’s an example that uses functional options to create a new House struct:

h := NewHouse(
  WithConcrete(),
  WithoutFireplace(),
)

Here, NewHouse is a constructor function. WithConcrete and WithFireplace are options passed to the constructor to modify the return value.

options pattern illustrated

We will soon see why WithConcrete and WithFireplace are called “functional” options and how they are useful over regular function arguments.

2. Functional Options Pattern in Go examples

Let’s have a look at a code snippet and let’s see what options we can use and how and when the functional options pattern can be useful for us.

2.1. Use Options Pattern for config setting

package main

import "fmt"

type Config struct {
    Host     string
    Port     int
    Username string
    Password string
}

type Option func(*Config)

func WithHost(host string) Option {
    return func(c *Config) {
        c.Host = host
    }
}

func WithPort(port int) Option {
    return func(c *Config) {
        c.Port = port
    }
}

func WithCredentials(username, password string) Option {
    return func(c *Config) {
        c.Username = username
        c.Password = password
    }
}

func NewConfig(options ...Option) *Config {
    config := &Config{
        Host:     "localhost",
        Port:     8080,
        Username: "",
        Password: "",
    }

    for _, option := range options {
        option(config)
    }

    return config
}

In the example above, we have a Config struct that represents some configuration settings. We define functional options (WithHost, WithPort, WithCredentials) that modify the Config struct by applying the desired changes. Each functional option is a function that takes a pointer Config as its parameter.

The NewConfig function acts as a factory function that creates a new Config instance. It accepts a variable number of functional options and applies them to the default configuration by iterating over the options and invoking them with the Config instance.

Main function:

func main() {
    // Create a default configuration
    defaultConfig := NewConfig()
    fmt.Println("Default Configuration:", defaultConfig)

    // Create a configuration with custom host and port
    customConfig := NewConfig(
        WithHost("example.com"),
        WithPort(9000),
    )
    fmt.Println("Custom Configuration:", customConfig)

    // Create a configuration with credentials
    credentialConfig := NewConfig(
        WithCredentials("user", "pass"),
    )
    fmt.Println("Credential Configuration:", credentialConfig)
}

In the main function, we demonstrate how to use the functional options to create different configurations. We create a default configuration, a custom configuration with a different host and port, and a configuration with credentials.

The Functional Options pattern provides a flexible way to configure objects or functions without the need for multiple constructors or complex parameter lists. It allows you to easily extend and modify behavior by providing optional parameters as functional arguments.

2.2. Use Options Pattern for Validator

Here's an example of a validator implementation in Go that utilizes the optional pattern:

package main

import (
    "errors"
    "fmt"
)

type User struct {
    Name     string
    Email    string
    Age      int
    Language string
}

type UserValidator struct {
    user        User
    validateAge bool
    validateLanguage bool
}

type UserOption func(*UserValidator)

func WithAgeValidation(validate bool) UserOption {
    return func(v *UserValidator) {
        v.validateAge = validate
    }
}

func WithLanguageValidation(validate bool) UserOption {
    return func(v *UserValidator) {
        v.validateLanguage = validate
    }
}

func NewUserValidator(user User, options ...UserOption) (*UserValidator, error) {
    validator := &UserValidator{
        user: user,
        validateAge: true,
        validateLanguage: true,
    }

    for _, option := range options {
        option(validator)
    }

    err := validator.validate()
    if err != nil {
        return nil, err
    }

    return validator, nil
}

func (v *UserValidator) validate() error {
    if v.validateAge && (v.user.Age < 18 || v.user.Age > 60) {
        return errors.New("Invalid age")
    }

    if v.validateLanguage && (v.user.Language != "English" && v.user.Language != "Spanish") {
        return errors.New("Invalid language")
    }

    return nil
}

In this example, we have a User struct representing user data. The UserValidator struct holds the user to be validated and additional validation flags. We define functional options (WithAgeValidation and WithLanguageValidation) to control which validations are enabled or disabled.

The NewUserValidator function creates a new instance of UserValidator and applies the provided options. It also invokes the validate method to perform the validations. If any validation fails, it returns an error.

func main() {
    validUser := User{
        Name:     "John Doe",
        Email:    "johndoe@example.com",
        Age:      30,
        Language: "English",
    }

    validator, err := NewUserValidator(validUser)
    if err != nil {
        fmt.Println("Validation Error:", err)
        return
    }

    fmt.Println("User is valid:", validator.user)

    invalidUser := User{
        Name:     "Jane Smith",
        Email:    "janesmith@example.com",
        Age:      17,
        Language: "German",
    }

    validator, err = NewUserValidator(invalidUser, WithAgeValidation(false), WithLanguageValidation(false))
    if err != nil {
        fmt.Println("Validation Error:", err)
        return
    }

    fmt.Println("User is valid:", validator.user)
}

The validate method checks whether age and language validations are enabled and performs the corresponding checks. If a validation fails, it returns an error.

In the main function, we create a valid user and validate it using the default options. Then we create an invalid user and disable both age and language validations using the functional options.

By utilizing the optional pattern with functional options, you can selectively enable or disable specific validations based on your requirements, providing flexibility and customization to the validation process.

2.3. Use Options Pattern for Paginator

Here's an example of a paginator implementation in Go that uses the optional pattern:

package main

import (
    "fmt"
)

type Paginator struct {
    pageSize   int
    totalItems int
}

type PaginatorOption func(*Paginator)

func WithPageSize(size int) PaginatorOption {
    return func(p *Paginator) {
        p.pageSize = size
    }
}

func WithTotalItems(total int) PaginatorOption {
    return func(p *Paginator) {
        p.totalItems = total
    }
}

func NewPaginator(options ...PaginatorOption) *Paginator {
    paginator := &Paginator{
        pageSize:   10,
        totalItems: 0,
    }

    for _, option := range options {
        option(paginator)
    }

    return paginator
}

func (p *Paginator) CalculateTotalPages() int {
    if p.totalItems <= 0 {
        return 0
    }

    return (p.totalItems + p.pageSize - 1) / p.pageSize
}

In the example above, we have a Paginator struct that represents a pagination configuration. It includes fields for pageSize (number of items per page) and totalItems (total number of items to paginate).

The PaginatorOption is a functional option that modifies the Paginator struct by applying the desired changes. Each functional option is a function that takes a pointer to Paginator as its parameter.

The NewPaginator function creates a new Paginator instance and applies the provided options. It iterates over the options and invokes them with the Paginator instance.

The CalculateTotalPages method calculates the total number of pages based on the pageSize and totalItems fields. It uses integer division and ceiling rounding to ensure an accurate result.

func main() {
    defaultPaginator := NewPaginator()
    fmt.Println("Default Paginator - Total Pages:", defaultPaginator.CalculateTotalPages())

    customPaginator := NewPaginator(
        WithPageSize(20),
        WithTotalItems(100),
    )
    fmt.Println("Custom Paginator - Total Pages:", customPaginator.CalculateTotalPages())
}

In the main function, we demonstrate how to use the optional pattern to create different paginator configurations. We create a default paginator and calculate the total pages. Then, we create a custom paginator with a larger page size and total items, and calculate the total pages again.

By utilizing the optional pattern with functional options, you can easily customize the paginator behavior by providing optional parameters. It allows you to create different paginator configurations based on your specific requirements.

3. Why and When to use Options Pattern?

The Options Pattern is commonly used in software development to provide flexibility and customization when configuring objects or functions. Here are some reasons why and when you might consider using the Options Pattern:

  1. Flexible Configuration: The Options Pattern allows you to configure objects or functions with a varying number of optional parameters. It provides a way to specify only the desired configuration settings, keeping the interface clean and concise.

  2. Avoiding Constructor Overloading: In languages that do not support method overloading, constructors with multiple parameters can quickly become unwieldy. By using the Options Pattern, you can eliminate the need for numerous constructor variations, making the code more maintainable and readable.

  3. Default Values: The Options Pattern enables you to define default values for optional parameters. Clients can choose to override these defaults by providing their own values. This approach provides sensible defaults while still allowing customization when needed.

  4. Extensibility: The Options Pattern makes it easy to add new configuration options in the future without modifying existing code. New options can be introduced as additional functional parameters, ensuring backward compatibility and minimizing the impact on existing code.

  5. Enhanced Readability: By using named parameters, the Options Pattern improves code readability and self-documentation. It makes it clear which parameters are being configured and allows for more expressive code.

  6. Clear Intent: The Options Pattern explicitly communicates the intent of the configuration, as the functional options are self-explanatory and provide a clear indication of what behavior is being modified.

The Options Pattern is particularly beneficial in scenarios where you have objects or functions with numerous configuration settings, and not all of them are needed or relevant for every use case. It allows you to create more flexible and customizable APIs while maintaining a clean and straightforward interface.

However, it's important to note that the Options Pattern introduces some additional complexity, as it requires defining and managing functional options. Therefore, it's recommended to carefully consider the trade-offs and evaluate whether the benefits of flexibility and configurability outweigh the added complexity in your specific use case.