Adding a Caching Layer for Performance Optimization in Go

You can add a transparent caching layer to the golang-clean-architecture repository by implementing a decorator pattern that wraps the UserRepository interface, checking an in-memory store before falling back to the database for read-heavy operations like FindAll.

Adding a caching layer for performance optimization in Go is essential when working with read-heavy workloads in clean architecture projects. The manakuro/golang-clean-architecture repository provides an ideal foundation for this pattern because its strict separation between domain, use-case, and adapter layers allows you to inject cache decorators without modifying business logic. By wrapping the existing repository implementation with a caching decorator, you eliminate redundant database queries while maintaining the abstraction that the use-case layer depends on.

Understanding the Clean Architecture Boundaries

The repository follows a strict Clean Architecture layout that makes caching straightforward to implement. The use-case layer depends only on interfaces, never concrete implementations.

Domain and Repository Interfaces

The contract between business logic and data access lives in pkg/usecase/repository/user.go. This interface defines the methods that any user repository must implement, including FindAll for fetching records and Create for inserting them. Because the UserUsecase constructor in pkg/usecase/usecase/user.go accepts this interface via NewUserUsecase, you can substitute the concrete GORM repository with a cached version without touching the business logic.

Concrete Implementations

The actual database logic resides in pkg/adapter/repository/user.go, which implements the UserRepository interface using GORM. This concrete type queries the MySQL database directly, making it the perfect target for caching optimization.

Implementing the Cache Contract

A cache sits between the use-case and repository layers. You define a minimal contract that supports get, set, and delete operations, then implement it using an in-memory store or external system like Redis.

Cache Interface Definition

Create pkg/cache/cache.go to define the contract that all cache implementations must satisfy:

package cache

type Cache interface {
    // Get fetches a value by key; returns (value, true) if present.
    Get(key string) (interface{}, bool)
    // Set stores a value with an expiration.
    Set(key string, value interface{}, ttlSeconds int)
    // Delete removes a key.
    Delete(key string)
}

In-Memory Implementation

For local development or single-instance deployments, implement the interface using github.com/patrickmn/go-cache in pkg/cache/memory.go:

package cache

import (
    "time"

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

type memoryCache struct {
    c *cache.Cache
}

func NewMemoryCache(defaultTTL, cleanupInterval time.Duration) Cache {
    return &memoryCache{c: cache.New(defaultTTL, cleanupInterval)}
}

func (m *memoryCache) Get(key string) (interface{}, bool) { 
    return m.c.Get(key) 
}

func (m *memoryCache) Set(key string, value interface{}, ttl int) {
    m.c.Set(key, value, time.Duration(ttl)*time.Second)
}

func (m *memoryCache) Delete(key string) { 
    m.c.Delete(key) 
}

Creating the Cache Decorator

The decorator pattern allows you to add caching behavior without modifying the existing GORM repository. You create a new struct that embeds both the original repository and the cache, implementing the same UserRepository interface.

Wrapping the UserRepository

Create pkg/adapter/repository/user_cache.go to implement the caching logic:

package repository

import (
    "encoding/json"
    "fmt"
    
    "golang-clean-architecture/pkg/cache"
    "golang-clean-architecture/pkg/domain/model"
    "golang-clean-architecture/pkg/usecase/repository"
)

type cachedUserRepository struct {
    repo  repository.UserRepository
    cache cache.Cache
}

// NewCachedUserRepository wraps a concrete repo with caching.
func NewCachedUserRepository(r repository.UserRepository, c cache.Cache) repository.UserRepository {
    return &cachedUserRepository{repo: r, cache: c}
}

func (c *cachedUserRepository) cacheKeyAll() string { 
    return "users:all" 
}

func (c *cachedUserRepository) FindAll(_ []*model.User) ([]*model.User, error) {
    key := c.cacheKeyAll()
    if data, ok := c.cache.Get(key); ok {
        // Cache hit – deserialize.
        var users []*model.User
        if err := json.Unmarshal(data.([]byte), &users); err == nil {
            return users, nil
        }
        // If unmarshalling fails, fall back to DB.
    }

    // Cache miss – fetch from DB.
    users, err := c.repo.FindAll(nil)
    if err != nil {
        return nil, err
    }
    // Store result in cache (5‑minute TTL).
    if payload, err := json.Marshal(users); err == nil {
        c.cache.Set(key, payload, 300)
    }
    return users, nil
}

func (c *cachedUserRepository) Create(u *model.User) (*model.User, error) {
    created, err := c.repo.Create(u)
    if err == nil {
        // Invalidate the "list all" cache because data changed.
        c.cache.Delete(c.cacheKeyAll())
    }
    return created, err
}

Cache Invalidation Strategy

When data mutates through Create, the decorator immediately calls c.cache.Delete(c.cacheKeyAll()) to invalidate the list cache. This ensures eventual consistency—subsequent reads will miss the cache and fetch fresh data from MySQL. You can extend this pattern to individual entity caches by generating keys based on record IDs and invalidating them during update or delete operations.

Wiring the Dependencies in Main

The final step injects the decorated repository into the use-case layer inside cmd/app/main.go. You instantiate the concrete GORM repository, wrap it with the cache decorator, then pass the wrapped version to NewUserUsecase.

package main

import (
    "time"

    "golang-clean-architecture/pkg/adapter/repository"
    "golang-clean-architecture/pkg/cache"
    "golang-clean-architecture/pkg/config"
    "golang-clean-architecture/pkg/infrastructure/datastore"
    "golang-clean-architecture/pkg/infrastructure/router"
    "golang-clean-architecture/pkg/usecase"
    userepo "golang-clean-architecture/pkg/usecase/repository"
)

func main() {
    cfg := config.Load()
    db := datastore.NewDB(cfg) // returns *gorm.DB

    // Concrete repository
    userRepo := repository.NewUserRepository(db)

    // Cache implementation
    memCache := cache.NewMemoryCache(10*time.Minute, 15*time.Minute)

    // Decorated repository with caching
    cachedRepo := repository.NewCachedUserRepository(userRepo, memCache)

    // Use‑case gets the cached repository
    userUC := usecase.NewUserUsecase(cachedRepo, datastore.NewDBRepository(db))

    // Router receives the use‑case
    r := router.NewRouter(userUC)
    r.Run()
}

Because NewUserUsecase expects the interface userepo.UserRepository, the decorated cachedRepo satisfies the contract transparently.

Performance Benefits and Trade-offs

Implementing this pattern delivers measurable performance improvements while preserving architectural integrity:

  • Separation of Concerns: Cache logic lives in pkg/cache and pkg/adapter/repository/user_cache.go, keeping domain and use-case code pure.
  • Testability: You can mock the UserRepository interface in unit tests or test the decorator independently by injecting a mock cache.
  • Swappable Implementations: Replace memoryCache with a Redis-backed implementation by changing only the cache.NewMemoryCache call in main.go.
  • Reduced Database Load: Subsequent calls to FindAll hit the in-memory store, eliminating redundant GORM queries and MySQL round-trips.
  • Eventual Consistency: Cache invalidation on write operations ensures data freshness without requiring distributed transaction complexity.

Summary

  • Define a cache contract in pkg/cache/cache.go with Get, Set, and Delete methods to abstract caching mechanics from business logic.
  • Implement the decorator pattern in pkg/adapter/repository/user_cache.go by wrapping the concrete UserRepository and checking the cache before database calls.
  • Use JSON serialization to store complex objects in the cache, handling cache hits by unmarshaling and misses by fetching from GORM then caching the result.
  • Invalidate caches on write operations like Create to maintain data consistency between the cache and the MySQL database.
  • Wire dependencies in cmd/app/main.go by passing the decorated repository to NewUserUsecase, allowing the use-case layer to remain unaware of caching implementation details.

Frequently Asked Questions

Where should the cache logic reside in clean architecture?

The cache implementation belongs in the infrastructure or adapter layer, never in the domain or use-case layers. In the golang-clean-architecture repository, place the cache contract in pkg/cache and the repository decorator in pkg/adapter/repository. This maintains the dependency rule: inner layers (domain/use-case) depend only on interfaces, while outer layers (adapters/infrastructure) provide concrete implementations including caching.

How do I handle cache invalidation when data changes?

Implement invalidation logic inside the decorator's write methods. In user_cache.go, the Create method calls c.cache.Delete(c.cacheKeyAll()) immediately after a successful database insert. This removes the stale "list all" entry, forcing the next read to fetch fresh data. For individual records, generate cache keys based on entity IDs and delete them during update or delete operations.

Can I replace the in-memory cache with Redis or Memcached?

Yes. The Cache interface in pkg/cache/cache.go abstracts the storage mechanism, so you can implement a redisCache struct that uses go-redis or similar clients without modifying the decorator or use-case code. Simply implement Get, Set, and Delete using Redis commands, then swap the implementation in main.go by replacing cache.NewMemoryCache with your Redis constructor.

How does this pattern affect unit testing?

The decorator pattern preserves testability because the UserUsecase still depends on the UserRepository interface. In tests, you can inject a mock repository directly into the use-case to test business logic, or test the decorator separately by mocking the underlying repository and the cache interface. The cache implementation itself can be tested with a real in-memory instance or a stub to verify TTL and invalidation behavior.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →