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
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
}
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)
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)
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
}
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)
}
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)
}
}
}
(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
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)