DEV Community

Cover image for How to Add Redis Caching Without Touching Your Business Logic
Yar Khan
Yar Khan

Posted on

How to Add Redis Caching Without Touching Your Business Logic

A practical guide to the Decorator Pattern in Go — and why "how many files did this change touch" is the real test of clean architecture.


The problem

Most Go backends end up with some version of this layering:

Handler → Service → Repository
Enter fullscreen mode Exit fullscreen mode

At some point, a read-heavy piece of the system starts getting hit hard — a listing endpoint, a dashboard feed, anything queried far more often than it's written to. The obvious fix is caching. And the obvious first instinct is to open up the service layer and start adding Redis calls directly:

func (s *Service) GetAll(ctx context.Context) ([]Item, error) {
    cached, err := s.redis.Get(ctx, "items:all")
    if err == nil {
        var list []Item
        json.Unmarshal([]byte(cached), &list)
        return list, nil
    }

    list, err := s.repo.GetAll(ctx)
    // ...marshal, cache, return
}
Enter fullscreen mode Exit fullscreen mode

It works. It's also the wrong instinct — and this is why.


Why "just add it to the service" is a trap

Once caching logic lives inside the service, every method that touches data starts accumulating cache-read, cache-write, and invalidation logic sitting right next to the actual business rules. A Create method that used to be a few lines of validation now also needs to know cache key formats, TTLs, and what to invalidate on write.

Two problems fall out of this immediately:

1. The service becomes hard to test in isolation. Testing that Create rejects invalid input now requires wiring up a fake Redis client too, even though the test has nothing to do with caching.

2. Caching becomes a leaky concern. A service's job is to enforce business rules and orchestrate. The moment it also knows how data is persisted-and-cached, two things that change for completely different reasons are now coupled in the same file. A TTL tweak and a validation rule change suddenly touch the same code.

Here's a useful test to apply to any architecture decision:

If this dependency were ripped out completely, how many files would need to change?

With caching embedded directly in the service, the answer is "the service itself, plus every test file for it." That's a smell. A well-isolated concern should be addable and removable as a localized, additive change — not a rewrite of things that have nothing to do with it.


The fix: caching is a decorator, not a service concern

The key insight: a cache-aside repository is still a repository. From the service's point of view, it doesn't matter whether a method hits the database directly or checks Redis first — it just needs something that satisfies the repository interface. So instead of teaching the service about Redis, build a second implementation of the repository interface that wraps the real one.

Service → Repository (interface)
                │
       ┌────────┴────────┐
       │                 │
CachedRepository    RealRepository
 (checks Redis        (talks to
  first, falls          the DB)
  through to inner)
Enter fullscreen mode Exit fullscreen mode

CachedRepository implements the exact same interface as the real database-backed implementation. It holds a reference to that real implementation internally and only calls it on a cache miss. The service never knows the difference — it's still just calling methods on a Repository.


Directory structure

A layout that keeps this separation honest looks roughly like this:

domain/
├── model.go              # domain struct(s), sentinel errors
├── repository.go         # Repository interface definition
├── service.go             # business logic — validation, orchestration. Zero cache awareness.
├── cached_repository.go  # decorator — implements Repository, wraps another Repository + cache

handler/
└── handler.go             # HTTP layer — request binding, response mapping

repository/
└── postgres_repository.go # the real database implementation of Repository

cache/
└── client.go               # generic, domain-agnostic cache wrapper (Get/Set/Delete/locks)
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out:

  • The cache client (cache/client.go) knows nothing about any specific domain. It's a thin, reusable wrapper — Get, Set, Delete, Exists, maybe a distributed lock. Any part of the system can use it. It has zero business logic in it.
  • The decorator (cached_repository.go) knows nothing about HTTP or database specifics. It only depends on the repository interface and the generic cache client. It's the one place where caching policy lives — key naming, TTLs, what gets invalidated on which write.

Transport (the cache client) is reusable across the whole application. Policy (the decorator) is specific to the domain it caches and lives right next to it — not buried in a shared file, not scattered across services.


The code

The interface everything depends on

type Repository interface {
    Create(ctx context.Context, item *Item) error
    GetAll(ctx context.Context) ([]Item, error)
    GetByID(ctx context.Context, id uuid.UUID) (*Item, error)
    Delete(ctx context.Context, id uuid.UUID) error
}
Enter fullscreen mode Exit fullscreen mode

Nothing unusual here — this is exactly the interface a plain database-backed repository would implement with no caching involved at all. That's the point: introducing caching does not change this interface.

The service — completely unaware caching exists

type Service struct {
    repo Repository
}

func NewService(repo Repository) *Service {
    return &Service{repo: repo}
}

func (s *Service) Create(ctx context.Context, item *Item) error {
    item.Name = strings.TrimSpace(item.Name)
    if item.Name == "" {
        return ErrEmptyName
    }
    return s.repo.Create(ctx, item)
}

func (s *Service) GetAll(ctx context.Context) ([]Item, error) {
    return s.repo.GetAll(ctx)
}

func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
    return s.repo.Delete(ctx, id)
}
Enter fullscreen mode Exit fullscreen mode

There is no cache import in this file. No key naming, no TTL, nothing. If every trace of Redis were deleted from the codebase, this file would not need a single edit.

The decorator — where caching actually lives

type CachedRepository struct {
    inner  Repository // the real repository — called only on a cache miss
    cache  *cache.Client
}

func NewCachedRepository(inner Repository, c *cache.Client) *CachedRepository {
    return &CachedRepository{inner: inner, cache: c}
}

const (
    cacheVersion = "v1" // bump if the domain struct's shape ever changes
    listTTL      = 2 * time.Minute
)

func keyAll() string { return "items:" + cacheVersion + ":all" }

func (c *CachedRepository) GetAll(ctx context.Context) ([]Item, error) {
    key := keyAll()

    if list, ok := c.getList(ctx, key); ok {
        return list, nil // cache hit — database never touched
    }

    list, err := c.inner.GetAll(ctx) // cache miss — fall through to the real repository
    if err != nil {
        return nil, err
    }

    c.setList(ctx, key, list, listTTL)
    return list, nil
}

func (c *CachedRepository) Create(ctx context.Context, item *Item) error {
    if err := c.inner.Create(ctx, item); err != nil {
        return err
    }
    c.invalidate(ctx, keyAll())
    return nil
}

func (c *CachedRepository) Delete(ctx context.Context, id uuid.UUID) error {
    if err := c.inner.Delete(ctx, id); err != nil {
        return err
    }
    c.invalidate(ctx, keyAll())
    return nil
}

func (c *CachedRepository) invalidate(ctx context.Context, keys ...string) {
    for _, k := range keys {
        if err := c.cache.Delete(ctx, k); err != nil {
            fmt.Printf("err: %v", err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

(getList/setList are small marshal/unmarshal helpers around the cache client — omitted here for brevity, but they're the only place encoding/json shows up in this file.)

Wiring — the only place that changes

// main.go — composition root
realRepo := repository.NewPostgresRepository(db)
cachedRepo := domain.NewCachedRepository(realRepo, cacheClient, logger)
svc := domain.NewService(cachedRepo) // ← service receives the cached one
Enter fullscreen mode Exit fullscreen mode

That's the entire integration. Service is constructed exactly as it would be without caching — it just receives cachedRepo instead of realRepo. Since it's typed against the Repository interface either way, it has no way of knowing which one it got.


Why this is the actual point

Apply the test from earlier: if caching were ripped out entirely, how much would change?

With the decorator: delete cached_repository.go, change one line in main.go back to svc := domain.NewService(realRepo). Done. The handler, the service, the business-rule tests — none of it moves.

That's what clean architecture buys in practice. It's not about folder naming conventions or hitting some target number of layers — it's about the blast radius of a change matching its actual scope. Adding caching is a caching-shaped change. It shouldn't require editing files whose job is business rules.

What this means for testing

  • Service tests run against a mock Repository — no cache involved at all. Fast, minimal setup, and they test exactly one thing: business rules.
  • Decorator tests run independently: cache hit returns without touching the inner repository, cache miss falls through and populates the cache, writes invalidate the right keys. These tests carry zero business-rule logic — they only verify caching behavior.

Two test suites, two concerns, no overlap, no reason to touch one when changing the other.


The takeaway

The decorator pattern isn't a clever trick — it falls straight out of programming against interfaces instead of concrete types. Once a service depends on a Repository interface rather than a specific struct, anything satisfying that interface can be swapped in underneath it, including something that wraps another implementation of itself.

That's the difference between a caching layer and a caching tangle. And it's not specific to Redis — the same pattern applies to adding retries, metrics, logging, or any other cross-cutting concern to something that already works, without touching the thing itself.


Pattern applies regardless of stack — shown here in Go with a SQL-backed repository and Redis, but the shape is language-agnostic.

Top comments (0)