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.
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:
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.
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.
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.
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.
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.
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.