Skip to content
Caching

Caching

The cache package provides a small, byte-oriented cache abstraction for values that are expensive to recompute — rendered templates, serialized API payloads, or the result of an expensive query. The shipped implementation is an in-process map; the interface is the extension point for a shared cache such as memcached or Redis.

MemoryCache

MemoryCache is the interface every implementation satisfies:

type MemoryCache interface {
	Get(key string, defaultValue []byte) ([]byte, error)
	GetOrCreate(key string, factory func() []byte) ([]byte, error)
	Set(key string, value []byte, options *SetOptions) error
	Remove(key string) error
}

The cache stores []byte, so structured values are encoded and decoded by the caller. Keys are plain strings, and there is no namespacing or tagging built in, so include anything that distinguishes tenants or entity types in the key yourself.

methodbehavior
Get(key, defaultValue)returns the cached bytes, or defaultValue on a miss; a miss is not cached
GetOrCreate(key, factory)returns the cached bytes, or calls factory on a miss and caches the result
Set(key, value, options)stores bytes under key
Remove(key)deletes key; removing a key that is not present is not an error

The distinction between Get and GetOrCreate is the important one. Get is a pure read — it never writes — so it suits a fallback you already have in hand, such as a default or a value fetched from the database. GetOrCreate memoizes: it invokes factory only on a miss and stores what the factory returns.

// Read-through: falls back to a value we already have, without caching the miss.
cached, err := c.Get(key, renderDefault())
if err != nil {
	return err
}

// Memoize: the expensive work only happens on a miss.
body, err := c.GetOrCreate(key, func() []byte {
	return renderExpensiveReport(id)
})

SetOptions is currently an empty struct, a placeholder for per-write options such as a custom expiration. Callers must pass a *SetOptions — nil is accepted by the current implementations — so prefer &cache.SetOptions{} for forward compatibility.

MapCache

MapCache is the in-process implementation. It is safe for concurrent use, and NewMapCache() constructs one with a five-minute TTL:

c := cache.NewMapCache()

Each entry is a MapCacheItem holding the bytes and an absolute expiration time. Expiration is evaluated lazily: an expired entry is dropped the next time its key is read, not by a background sweeper. Two consequences follow. A key that is written and then never read again holds onto its bytes indefinitely, so caches holding many distinct keys should have their lifetimes bounded by the process. And because expiry is only noticed on access, Get and GetOrCreate can both return a miss for a key that is present but stale, and will then rebuild or re-store it.

The TTL is fixed at construction and is not currently adjustable through the public API; to use a different one, register your own MemoryCache rather than constructing a MapCache.

Registering the cache

cache.Register(ctx) is a bootstrap step that registers a MemoryCache as a lazy singleton, so every resolve in the process shares one instance:

kernel.Register(func(ctx context.Context, c *config.Config) {
	cache.Register(ctx)
})

From then on any struct can inject the interface and get the shared cache:

type ReportHandler struct {
	Cache  cache.MemoryCache `inject:""`
	Logger *slog.Logger      `inject:""`
}

Inject the MemoryCache interface rather than *MapCache — that is what allows a different implementation to be registered in its place.

Using a shared cache

To back the interface with a network cache, implement MemoryCache over that client and register it in place of the built-in one:

type redisCache struct {
	client *redis.Client
	ttl    time.Duration
}

func (c *redisCache) Get(key string, defaultValue []byte) ([]byte, error) {
	b, err := c.client.Get(context.Background(), key).Bytes()
	if errors.Is(err, redis.Nil) {
		return defaultValue, nil
	}
	return b, err
}

// GetOrCreate, Set, and Remove follow the same contract.

di.RegisterLazySingleton(ctx, func() (cache.MemoryCache, error) {
	return &redisCache{client: client, ttl: 5 * time.Minute}, nil
})

Two things to keep in mind when moving off the in-process map. A shared cache is network-visible, so keys and values must not carry data that is sensitive per-user without being scoped in the key. And a distributed cache loses the in-process guarantee that a read and a subsequent write are ordered, so GetOrCreate is a cache stampede risk: several callers can miss at once and run factory concurrently. The in-process MapCache has the same race, so the factory should be cheap or the caller should coalesce the work.