6. Caching layer

Every GetOrder call right now would hit Postgres directly. That’s fine at low volume, but order lookups are exactly the kind of read that’s cheap to cache and expensive to keep re-querying — so before writing the service layer that ties everything together, let’s give it a cache to read through. Same pattern as the last chapter: declare what’s needed as an interface, then build a real implementation against Redis.

Declare the cache boundary

Create cache/cache.go:

package cache

import (
	"context"
	"errors"
	"uuid"

	"example.com/servoorders/internal/domain"
)

var ErrMiss = errors.New("cache: miss")

type OrderCache interface {
	Get(ctx context.Context, id uuid.UUID) (*domain.Order, error)
	Set(ctx context.Context, o *domain.Order) error
	Invalidate(ctx context.Context, id uuid.UUID) error
}

ErrMiss deserves a moment of thought before you move past it. It would be tempting to reuse domain.ErrNotFound here — a cache miss and “this order doesn’t exist” can look like the same situation from the outside. They aren’t: domain.ErrNotFound means the repository looked and the row genuinely isn’t there; ErrMiss just means “the cache doesn’t have an answer, ask the repository instead.” Give them different sentinels now, in this package, and the service layer we write in the next chapter will be able to tell the two apart without any extra work.

Implement it against Redis

Create cache/redis/redis.go. Start with the type, a fixed TTL, and the constructor:

package redis

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"time"
	"uuid"

	"example.com/servoorders/internal/cache"
	"example.com/servoorders/internal/config"
	"example.com/servoorders/internal/domain"
	goredis "github.com/redis/go-redis/v9"
)

const ttl = 5 * time.Minute

type Cache struct {
	client *goredis.Client
}

var _ cache.OrderCache = (*Cache)(nil)

const envPrefix = "REDIS_"

type Config struct {
	Addr string `env:"ADDR,required"`
}

func NewConfig(src config.Source) (*Config, error) {
	return config.Parse[Config](src, envPrefix)
}

func New(cfg *Config) *Cache {
	return &Cache{client: goredis.NewClient(&goredis.Options{Addr: cfg.Addr})}
}

Why a fixed TTL instead of something more sophisticated? Because an order, once created, never changes in this service — there’s no “update order” endpoint — which makes the caching problem almost trivially easy. A short TTL just bounds how long a theoretically-stale read could last, and there’s no code path that writes to Postgres without also being able to update the cache in the same request (you’ll see that in CreateOrder, next chapter). A service where orders can change after creation needs either active invalidation on every write path, or has to accept eventually-stale reads as a deliberate trade-off — see chapter 21 for what changes once that’s true.

Add the same three capability methods you wrote for postgres.Store — the reasoning is identical, so it’s worth noticing the pattern is already starting to feel familiar:

func (c *Cache) Init(ctx context.Context) error {
	return c.client.Ping(ctx).Err()
}

func (c *Cache) Stop(context.Context) error {
	return c.client.Close()
}

func (c *Cache) Health(ctx context.Context) error {
	return c.client.Ping(ctx).Err()
}

Now Get, where the miss-versus-error decision from earlier actually gets used:

func (c *Cache) Get(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
	raw, err := c.client.Get(ctx, key(id)).Bytes()
	if errors.Is(err, goredis.Nil) {
		return nil, cache.ErrMiss
	}
	if err != nil {
		return nil, fmt.Errorf("redis: get: %w", err)
	}

	var o domain.Order
	if err := json.Unmarshal(raw, &o); err != nil {
		return nil, fmt.Errorf("redis: unmarshal: %w", err)
	}
	return &o, nil
}

goredis.Nil is go-redis’s own sentinel for “this key doesn’t exist” — translating it into cache.ErrMiss right here is the same move you made for pgx.ErrNoRows in the last chapter. Nothing above this package should ever need to import go-redis just to check an error. Set and Invalidate finish the interface:

func (c *Cache) Set(ctx context.Context, o *domain.Order) error {
	raw, err := json.Marshal(o)
	if err != nil {
		return fmt.Errorf("redis: marshal: %w", err)
	}
	if err := c.client.Set(ctx, key(o.ID), raw, ttl).Err(); err != nil {
		return fmt.Errorf("redis: set: %w", err)
	}
	return nil
}

func (c *Cache) Invalidate(ctx context.Context, id uuid.UUID) error {
	if err := c.client.Del(ctx, key(id)).Err(); err != nil {
		return fmt.Errorf("redis: del: %w", err)
	}
	return nil
}

func key(id uuid.UUID) string {
	return "order:" + id.String()
}

Try it against a real Redis

$ make up
$ TEST_REDIS_ADDR=localhost:6379 go test ./redis/... -v
=== RUN   TestGetOnEmptyKeyReturnsErrMiss
--- PASS: TestGetOnEmptyKeyReturnsErrMiss (0.01s)
=== RUN   TestSetThenGetRoundTrips
--- PASS: TestSetThenGetRoundTrips (0.00s)
=== RUN   TestInvalidateRemovesTheKey
--- PASS: TestInvalidateRemovesTheKey (0.00s)
PASS
ok  	example.com/servoorders/internal/cache/redis	0.174s

(make test-integration runs this alongside Postgres and NATS’s own tests together — shown separately here since this chapter is only about Redis so far.) One thing worth writing a test for specifically: a time.Time round-trips through JSON as RFC 3339 text, which isn’t nanosecond-precise. TestSetThenGetRoundTrips truncates to millisecond precision before comparing, for exactly that reason — don’t assume a value that’s been through the cache is byte-identical to the one you put in.

Diagnostics

  • redis: get: dial tcp ...: connect: connection refused — Redis isn’t running, or isn’t on the address REDIS_ADDR names. make up followed by docker ps confirms it’s actually up; redis-cli -h localhost ping answering PONG is the fastest independent check.
  • Stale data after a value should have changed — if this service ever grows an update path, this is the first place to look: an update that writes to Postgres but forgets to also call Set or Invalidate leaves the cache serving the old value until the TTL expires.

Do’s and don’ts

  • Do treat every cache error except ErrMiss as “log and fall back,” never as a request failure — a cache is an optimization, and a service that returns 500s when Redis hiccups has turned an optional dependency into a required one. You’ll see this pattern applied for real in chapter 8.
  • Do put a TTL on everything. An entry with no expiry that’s never explicitly invalidated is how a caching bug becomes a “why is this three days stale” incident.
  • Don’t reach for a distributed cache at all until a profiler or a load test says the database is actually the bottleneck. Redis here is instructive, not a default every service needs from day one.

Next

Chapter 7: Messaging layer — NATS, and publishing the event this service’s other consumers care about.