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/cacheandpkg/adapter/repository/user_cache.go, keeping domain and use-case code pure. - Testability: You can mock the
UserRepositoryinterface in unit tests or test the decorator independently by injecting a mock cache. - Swappable Implementations: Replace
memoryCachewith a Redis-backed implementation by changing only thecache.NewMemoryCachecall inmain.go. - Reduced Database Load: Subsequent calls to
FindAllhit 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.gowithGet,Set, andDeletemethods to abstract caching mechanics from business logic. - Implement the decorator pattern in
pkg/adapter/repository/user_cache.goby wrapping the concreteUserRepositoryand 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
Createto maintain data consistency between the cache and the MySQL database. - Wire dependencies in
cmd/app/main.goby passing the decorated repository toNewUserUsecase, 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →