Photo by Caspar Camille Rubin on Unsplash

In this post, I want to share a pattern that helps in configure/construct a service. You can see the final result code first for easier to get the context

package main

import (
	"time"

	"github.com/jkaveri/fluent-option/fluentcache"
)

func main() {
	connection := "localhost"
	password := "JA$2mAe%1@s5"
	db := 0
	readTimeout := 2 * time.Second
	writeTimeout := 2 * time.Second
	dialTimeout := 5 * time.Second

        // Configure redis cache fluent
	redisCache := fluentcache.NewRedisCache(
		fluentcache.UseStandalone(connection),
		fluentcache.WithDB(db),
		fluentcache.WithPassword(password),
		fluentcache.WithTimeoutPolicy(
			dialTimeout,
			readTimeout,
			writeTimeout,
		),
	)

	// set key
	_ = redisCache.Set("my_key", "my_value")
}

Problem

When design API in Golang I usually create a function that helps to configure the service.

// cache/cache.go
package cache

type RedisCache struct {
	connection string
	db         int
	password   string
}

// RedisCache create new redis cache
func NewRedisCache(connection, password string, db int) *RedisCache {
	return &RedisCache{
		connection: connection,
		password:   password,
		db:         db,
	}
}

func (*RedisCache) Set(key, value string) error {
	return nil
}

func (*RedisCache) Get(key string) (string, error) {
	return "", nil
}

I skipped some implementation detail to keep the code simple, you only need to focus the NewRedisCache function

Then I can init my RedisCache like this:

// main.go
package main

import "github.com/jkaveri/fluent-option/cache"

func main() {
	connection := "localhost"
	password := "JA$2mAe%1@s5"
	db := 0

	redisCache := cache.NewRedisCache(connection, password, 0)

	// set key
	_ = redisCache.Set("my_key", "my_value")
}

The configure looks good enough and I started to use that configure function in many places. Unfortunately, I was got a problem when I want to add more argument into the configure function because of I want to add more feature for my service, the problems are:

So we only have these options:

  • Add new arguments into existing function and update existing code. This option is worse because of that impact on the existing code. Furthermore, in case your API is the dependency of other packages and this option can is a breaking change

    // cachev2.go
    package cachev2
    
    import "time"
    
    type RedisCache struct {
        connection string
        db         int
        password   string
    
        maxRetries      int
        minRetryBackoff time.Duration
        maxRetryBackoff time.Duration
    }
    
    // RedisCache create new redis cache
    func NewRedisCache(
        connection, password string,
        db, maxRetries int,
        minRetryBackoff, maxRetryBackoff time.Duration,
    ) *RedisCache {
    
        return &RedisCache{
            connection:      connection,
            password:        password,
            db:              db,
            maxRetries:      maxRetries,
            minRetryBackoff: minRetryBackoff,
            maxRetryBackoff: maxRetryBackoff,
        }
    }
    
    func (*RedisCache) Set(key, value string) error {
        return nil
    }
    
    func (*RedisCache) Get(key string) (string, error) {
        return "", nil
    }
    
  • Create a new function with meaning name, creating a new function makes sense but what if we will need more arguments in future? Of course, you can say we need compliance with YAGNI, but sometimes if we have an option that more flexible so that the API more stable. In other words, in a future version of API, the API’s clients don’t struggle about the breaking changes

    package cachev3
    
    import "time"
    
    type RedisCache struct {
        connection string
        db         int
        password   string
    
        maxRetries      int
        minRetryBackoff time.Duration
        maxRetryBackoff time.Duration
    
        dialTimeout  time.Duration
        readTimeout  time.Duration
        writeTimeout time.Duration
    }
    
    // RedisCache create new redis cache
    func NewRedisCache(connection, password string, db int) *RedisCache {
        return &RedisCache{
            connection: connection,
            password:   password,
            db:         db,
        }
    }
    
    func NewRedisCacheWithRetryPolicy(
        connection, password string,
        db, maxRetries int,
        minRetryBackoff, maxRetryBackoff time.Duration,
    ) *RedisCache {
        rc := NewRedisCache(connection, password, db)
    
        rc.maxRetries = maxRetries
        rc.minRetryBackoff = minRetryBackoff
        rc.maxRetryBackoff = maxRetryBackoff
        return rc
    }
    
    func NewRedisCacheWithTimeoutPolicy(
        connection, password string,
        db, maxRetries int,
        dialTimeout, readTimeout, writeTimeout time.Duration,
    ) *RedisCache {
        rc := NewRedisCache(connection, password, db)
        rc.dialTimeout = dialTimeout
        rc.readTimeout = readTimeout
        rc.writeTimeout = writeTimeout
        return rc
    }
    
    func (*RedisCache) Set(key, value string) error {
        return nil
    }
    
    func (*RedisCache) Get(key string) (string, error) {
        return "", nil
    }
    

Two solutions above can be applied in some cases it is simple and easy for implementing but it doesn’t flexible because of when you add new arguments you need a new API signature.

Solution

The solution is the application of Closure and the High Order Function.

Please read the code then I will explain more about the pattern

fluentcache/cache.go

package fluentcache

import (
	"context"
	"crypto/tls"
	"net"
	"time"

	"github.com/go-redis/redis"
)

type ConfigureFunc = func(redisCache *RedisCache)

type DialerFunc = func(
	ctx context.Context,
	network,
	addr string,
) (net.Conn, error)

type OnConnectFunc = func(conn *redis.Conn) error

type RedisCache struct {
	sentinel   bool
	connection string
	db         int
	password   string

	sentinelAddrs    []string
	masterName       string
	sentinelPassword string

	dialer          DialerFunc
	onConnect       OnConnectFunc
	maxRetries      int
	minRetryBackoff time.Duration
	maxRetryBackoff time.Duration

	dialTimeout  time.Duration
	readTimeout  time.Duration
	writeTimeout time.Duration

	poolSize           int
	minIdleConns       int
	maxConnAge         time.Duration
	poolTimeout        time.Duration
	idleTimeout        time.Duration
	idleCheckFrequency time.Duration

	tLsConfig *tls.Config
}

// RedisCache create new redis cache
func NewRedisCache(configures ...ConfigureFunc) *RedisCache {
	var rc RedisCache
	for _, configure := range configures {
		configure(&rc)
	}
	return &rc
}

func (*RedisCache) Set(key, value string) error {
	return nil
}

func (*RedisCache) Get(key string) (string, error) {
	return "", nil
}

fluentcache/configure.go

package fluentcache

import (
	"crypto/tls"
	"time"
)

func UseStandalone(connection string) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.sentinel = false
		rc.connection = connection
	}
}

func UseSentinelRedis(
	masterName string,
	sentinelAddrs []string,
	sentinelPassword string,
) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.sentinel = true
		rc.masterName = masterName
		rc.sentinelAddrs = sentinelAddrs
		rc.sentinelPassword = sentinelPassword
	}
}

func WithPassword(password string) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.password = password
	}
}

func WithDB(db int) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.db = db
	}
}

func WithDialer(dialer DialerFunc) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.dialer = dialer
	}
}

func OnConnect(onConnectFunc OnConnectFunc) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.onConnect = onConnectFunc
	}
}

func WithRetryPolicy(
	maxRetries int,
	minRetryBackoff, maxRetryBackoff time.Duration,
) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.maxRetries = maxRetries
		rc.minRetryBackoff = minRetryBackoff
		rc.maxRetryBackoff = maxRetryBackoff
	}
}

func WithTimeoutPolicy(
	dialTimeout, readTimeout, writeTimeout time.Duration,
) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.dialTimeout = dialTimeout
		rc.readTimeout = readTimeout
		rc.writeTimeout = writeTimeout
	}
}

func WithConnectionPoolPolicy(
	poolSize, minIdleConns int,
	maxConnAge, poolTimeout, idleTimeout, idleCheckFrequency time.Duration,
) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.poolSize = poolSize
		rc.minIdleConns = minIdleConns
		rc.maxConnAge = maxConnAge
		rc.poolTimeout = poolTimeout
		rc.idleTimeout = idleTimeout
		rc.idleCheckFrequency = idleCheckFrequency
	}
}

func WithTLSOptions(tlsConfig *tls.Config) ConfigureFunc {
	return func(rc *RedisCache) {
		rc.tLsConfig = tlsConfig
	}
}

As you can see, instead of using many arguments with different types. I only use 1 type is ConfigureFunc with Variadic function.

The ConfigureFunc will take the RedisCache pointer as an argument and then ConfigureFunc can set the value for the RedisCache pointer.

With this pattern, my job is defining the fluent configure functions you can see them in fluentcache/configure.go. My fluent configure functions will return the ConfigureFunc (High Order Function) and the returned ConfigureFunc can access the argument of outer function because of Go has support Closure.

With this pattern you can have some advantages:

  • Configure service fluently configure fluently
  • Flexible API and reduce breaking changes flexible API and reduce breaking changes
  • Can be used for optional argument with a default value

Tradeoff

This pattern is good but it has some trade off

  • The configure function will not clear with API consumer. I need document it carefully to help API consumer know what API supports
  • ConfigureFunc is stateful so it isn’t good if I add new ConfigureFunc without understanding previous ConfigureFunc.

Conclusion

The fluent configure pattern is good but it requires extras effort to implement. So I don’t’ use always, I only use it when I see the API may be changed in the future. For example, I design an API for logging and I know I will change the way I log in future then I will use the fluent configure pattern

p.s: This pattern doesn’t new you can see this pattern in some popular package.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.