Skip to content

Repository files navigation

go-cache

CI Go Reference

A small, generic Go caching layer with a driver interface, TTL helpers, and a cache-aside Remember helper. Ships with in-memory and Redis drivers out of the box — write your own by implementing a four-method interface.

Why

Most projects end up hand-rolling the same "get from cache, else fetch and store" logic around every external call. go-cache factors that pattern out into one generic Remember method that works the same way regardless of the backing store, so swapping memory for Redis (or a custom driver) is a one-line change.

Install

go get github.com/PostScriptonLabs/go-cache

Requires Go 1.24+.

Architecture

  • Cache[Value] — the generic entry point, implements Cacher[Value] and owns backend-agnostic logic such as Remember.
  • Driver[Value] — the interface a storage backend implements: Get, Set, Has, Delete.
  • MemoryDriver[Value] — thread-safe in-process map with lazy expiry.
  • RedisDriver[Value] — Redis-backed driver with JSON encoding for non-string values, optional key prefixing, and TLS support.

Each driver has its own configuration options; Cache itself stays the same no matter which driver it wraps.

Quick start

In-memory cache

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/PostScriptonLabs/go-cache"
)

func main() {
	ctx := context.Background()

	c := cache.NewMemoryCache[string](
		cache.WithMemoryDefaultTTL(5 * time.Minute),
	)

	c.Set(ctx, "user:123", "John Doe", cache.DefaultTTL)
	c.Set(ctx, "otp:123", "1234", cache.WithTTL(30*time.Second))
	c.Set(ctx, "config:app", "production", cache.Forever)

	if value, found, err := c.Get(ctx, "user:123"); err == nil && found {
		fmt.Println("User:", value)
	}
}

Redis cache

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/PostScriptonLabs/go-cache"
)

func main() {
	ctx := context.Background()

	client, err := cache.NewRedisClient(
		ctx,
		cache.WithRedisAddr("localhost:6379"),
		cache.WithRedisDB(0),
	)
	if err != nil {
		fmt.Printf("connect to redis: %v\n", err)
		return
	}

	c := cache.NewRedisCache[string](client, cache.WithRedisDefaultTTL(5*time.Minute))

	c.Set(ctx, "user:123", "John Doe", cache.DefaultTTL)
	c.Set(ctx, "otp:123", "1234", cache.WithTTL(30*time.Second))
	c.Set(ctx, "config:app", "production", cache.Forever)

	if value, found, err := c.Get(ctx, "user:123"); err == nil && found {
		fmt.Println("User:", value)
	}
}

A runnable version of this lives in examples/redisREDIS_ADDR=localhost:6379 go run ./examples/redis.

Namespacing keys with a prefix

WithRedisPrefix prepends a fixed string to every key, which is handy when several applications share one Redis instance:

c := cache.NewRedisCache[string](
	client,
	cache.WithRedisPrefix("app:"), // "user:123" is stored as "app:user:123"
	cache.WithRedisDefaultTTL(5*time.Minute),
)

Authentication and TLS

client, err := cache.NewRedisClient(
	ctx,
	cache.WithRedisAddr("your-redis-host:6379"),
	cache.WithRedisUsername("default"),
	cache.WithRedisPassword("your-secure-password"),
	cache.WithRedisTLS(true),
	cache.WithRedisDB(0),
)

WithRedisTLS(true) skips certificate verification, which matches how managed Redis providers (e.g. AWS ElastiCache) commonly present self-signed certs. Pass false to disable TLS explicitly.

The Remember pattern

Remember is a cache-aside helper: look the key up, and only call fetch on a miss.

result, err := c.Remember(ctx, "expensive:calc", cache.DefaultTTL, func(ctx context.Context) (string, error) {
	// runs only when the key is absent from the cache
	return performExpensiveCalculation(ctx)
})

user, err := c.Remember(ctx, "api:user:123", cache.WithTTL(10*time.Minute), func(ctx context.Context) (User, error) {
	return fetchUserFromAPI(ctx, 123)
})

config, err := c.Remember(ctx, "app:config", cache.Forever, func(_ context.Context) (Config, error) {
	return loadConfigFromFile()
})

Behavior:

  1. Cache hit — the cached value is returned; fetch never runs.
  2. Cache missfetch runs, its result is stored with the given TTL, then returned.
  3. TTL expiry — the entry is dropped; the next call runs fetch again.
  4. Cache read error — treated like a miss, so a flaky cache backend degrades to "always fetch" instead of breaking the caller. If the subsequent Set also fails, Remember still returns the fetched value alongside a wrapped ErrCacheSetFailed, so callers can choose whether to treat it as fatal.

Sharing one Redis connection across several typed caches

A typical app needs more than one value type behind a cache (Cacher[User], Cacher[Config], Cacher[string], ...) while reusing a single Redis connection pool.

cmd/
  main.go
services/
  user_service.go    // uses Cacher[User]
  config_service.go  // uses Cacher[Config]
  auth_service.go    // uses Cacher[string]
// cmd/main.go
package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/PostScriptonLabs/go-cache"
	"your-app/services"
)

type Caches struct {
	Users   cache.Cacher[User]
	Configs cache.Cacher[Config]
	Strings cache.Cacher[string]
}

type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

type Config struct {
	APIKey   string `json:"api_key"`
	Endpoint string `json:"endpoint"`
}

func newCaches(ctx context.Context) (*Caches, error) {
	client, err := cache.NewRedisClient(
		ctx,
		cache.WithRedisAddr(env("REDIS_ADDR", "localhost:6379")),
		cache.WithRedisPassword(env("REDIS_PASSWORD", "")),
		cache.WithRedisPoolSize(20),
	)
	if err != nil {
		return nil, fmt.Errorf("create redis client: %w", err)
	}

	opts := []cache.RedisDriverOption{
		cache.WithRedisDefaultTTL(5 * time.Minute),
		cache.WithRedisPrefix("app-name:"),
	}

	return &Caches{
		Users:   cache.NewRedisCache[User](client, opts...),
		Configs: cache.NewRedisCache[Config](client, opts...),
		Strings: cache.NewRedisCache[string](client, opts...),
	}, nil
}

func env(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

func main() {
	caches, err := newCaches(context.Background())
	if err != nil {
		fmt.Println(err)
		return
	}

	_ = services.NewUserService(caches.Users)
	_ = services.NewConfigService(caches.Configs)
	_ = services.NewAuthService(caches.Strings)
}
// services/user_service.go
package services

import (
	"context"
	"fmt"
	"time"

	"github.com/PostScriptonLabs/go-cache"
)

type UserService struct {
	cache cache.Cacher[User]
}

func NewUserService(c cache.Cacher[User]) *UserService {
	return &UserService{cache: c}
}

func (s *UserService) GetUser(ctx context.Context, id int) (User, error) {
	key := cache.Key(fmt.Sprintf("id:%d", id))

	return s.cache.Remember(ctx, key, cache.WithTTL(time.Hour), func(ctx context.Context) (User, error) {
		return s.fetchFromDB(ctx, id)
	})
}

Each driver instance is created per value type, but every driver reuses the same *redis.Client (and therefore the same connection pool), so the number of open Redis connections doesn't grow with the number of cached types.

Alternative: one Cacher[string] for everything

If you'd rather cap the connection pool at one cache instance, use a single Cacher[string] and marshal/unmarshal JSON yourself at the call site:

func GetUser(ctx context.Context, c cache.Cacher[string], id int) (User, error) {
	key := cache.Key(fmt.Sprintf("user:%d", id))

	raw, err := c.Remember(ctx, key, cache.WithTTL(time.Hour), func(ctx context.Context) (string, error) {
		user := User{ID: id, Name: "John Doe", Email: "john@example.com"}
		data, err := json.Marshal(user)
		return string(data), err
	})
	if err != nil {
		return User{}, err
	}

	var user User
	err = json.Unmarshal([]byte(raw), &user)
	return user, err
}

Trade-off: fewer connections, but no compile-time type safety and manual serialization for every type. Prefer typed caches (Cacher[User], ...) unless you're specifically constrained on connection pool size or need to cache many ad-hoc types.

TTL options

  • cache.DefaultTTL — use the default TTL configured on the cache/driver.
  • cache.WithTTL(d) — a fixed duration.
  • cache.WithTTLUntil(t) — a duration computed from now until t, useful when converting an absolute expiry timestamp.
  • cache.Forever — no expiration.
var (
	OTPTTL     = cache.WithTTL(30 * time.Second)
	SessionTTL = cache.WithTTL(30 * time.Minute)
	UserTTL    = cache.WithTTL(time.Hour)
)

c.Set(ctx, "otp:123", "1234", OTPTTL)
c.Set(ctx, "user:456", userData, UserTTL)

API reference

Cacher[Value]

type Cacher[Value any] interface {
	Get(ctx context.Context, key Key) (value Value, found bool, err error)
	Set(ctx context.Context, key Key, value Value, ttl TTL) error
	Has(ctx context.Context, key Key) (bool, error)
	Delete(ctx context.Context, key Key) error
	Remember(ctx context.Context, key Key, ttl TTL, fetch func(ctx context.Context) (Value, error)) (Value, error)
}

Constructors

  • NewMemoryCache[Value](options ...MemoryOption) *Cache[Value]
  • NewRedisCache[Value](client *redis.Client, options ...RedisDriverOption) *Cache[Value]
  • NewRedisClient(ctx context.Context, options ...RedisClientOption) (*redis.Client, error)

Memory options

  • WithMemoryDefaultTTL(d time.Duration)

Redis client options

  • WithRedisAddr(addr string)
  • WithRedisUsername(username string)
  • WithRedisPassword(password string)
  • WithRedisDB(db int)
  • WithRedisTLS(enable bool)
  • WithRedisPoolSize(size int)
  • WithRedisMinIdleConns(conns int)
  • WithRedisMaxRetries(retries int)
  • WithRedisTimeouts(dial, read, write, idle, maxConnAge time.Duration)

Redis driver options

  • WithRedisDefaultTTL(d time.Duration)
  • WithRedisPrefix(prefix string)

The RedisDriver also exposes Client(), Close(), Ping(ctx), FlushDB(ctx), Keys(ctx, pattern), TTL(ctx, key), and Exists(ctx, keys...) for cases where you need to drop down to Redis-specific operations.

Testing

go test ./...

The Redis driver's tests run against miniredis and don't require a live Redis server.

License

MIT

About

Generic Go cache with in-memory and Redis drivers, TTL helpers, and a cache-aside Remember pattern

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages