I was recently reviewing some code and came across this interface designed for interacting with Redis:

import (
	"time"
)

type RedisPort interface {
	Set(key string, value string, ttl time.Duration) error
	Get(key string) (string, error)
	Del(keys ...string) error
	Exists(key string) (bool, error)
	SetJSON(key string, val any, ttl time.Duration) error
	GetJSON(key string, out any) error

	Close() error
}

At first glance, it gets the job done. But there are a few points that could make it even better.

Interfaces Should Be Descriptive

Interfaces or traits are descriptive, not blueprints for specific implementations. They describe the common behavior of a group of types.

For example, imagine describing a flower. You might mention its petals, color, and fragrance—properties common to all flowers. But you wouldn’t describe a Rose as an interface, that’s too specific.

Naming an interface RedisPort is too specific. It’s better to name it something more general, like CachePort.

Interfaces Should Be Cohesive

Another improvement is to remove the Close() method. It doesn’t align with the rest of the interface, which focuses on caching operations. Including Close() is like adding “can die” to describe a flower. Sure, everything dies, but it’s not relevant to the core behavior we’re describing

Interfaces should expose harmonious methods that embody a single responsibility or a cohesive set of behaviors.

Interfaces Can Support Partial Implementation

Some languages like Rust allow partial implementation of traits. While Go doesn’t support this natively with interfaces, you can achieve similar functionality using embedded structs with method promotion.

A Cleaner, More Flexible Interface

Let’s apply these improvements into a cleaner, more robust interface:

import (
	"encoding/json"
	"time"
)

type Option func(*options)

type options struct {
	// Time-To-Live for cached item.
	ttl time.Duration
}

func WithTTL(ttl time.Duration) Option {
    return func(opt *CacheOptions) { opt.ttl = ttl }
}

type CachePort interface {
	Set(key string, value string, opts ...Option) error
	Get(key string) (string, error)
	Delete(keys ...string) error
	Exists(key string) (bool, error)
}

type CacheWithJSON struct {
	CachePort
}

func (c CacheWithJSON) SetJSON(key string, val any, opts ...Option) error {
	bz, err := json.Marshal(val)
	if err != nil {
		return err
	}
	return c.Set(key, string(bz), opts...)
}

func (c CacheWithJSON) GetJSON(key string, out any) error {
	str, err := c.Get(key)
	if err != nil {
		return err
	}
	return json.Unmarshal([]byte(str), out)
}

With this setup, you only need to implement the core cache methods like Set and Get and the JSON helpers come built-in at no extra effort. Additionally, the interface is easily extensible through options, providing the flexibility to support additional features in the future.