Enhancing Data Access with the Decorator Pattern in Go

Matryoshka dolls
each shell wrapping the next.
The decorator pattern lets you add behaviour to an object by wrapping it, without changing the object itself. In Go, it’s a clean way to keep things like caching, logging, or metrics out of your core logic.
Go has no classes or inheritance, so the pattern is built with interfaces: every decorator implements the same interface as the thing it wraps. Below is a small data access layer with stackable behaviours.
Implementing Decorators for Data Access
To illustrate this, let us imagine we have a simple application that needs to fetch data, for example, a user’s profile. We want to fetch this data from a database, but to improve performance, we want to add a Cache layer. We also want to add Logging to monitor how long requests take.
classDiagram
class DataSource {
<< Interface >>
+ Get(id: string): string
}
class DatabaseSource {
+ Get(id: string): string
}
class CachedSource {
- Source: DataSource
- Cache: map[string]string
+ Get(id: string): string
}
class LoggedSource {
- Source: DataSource
+ Get(id: string): string
}
DataSource <|.. DatabaseSource : Implements
DataSource <|.. CachedSource : Decorates
DataSource <|.. LoggedSource : Decorates
To implement these functionalities, we define a common interface and then construct a base implementation and several decorators.
The Interface
First, we define the DataSource interface. This is the contract that both our real database fetcher and all decorators will fulfill.
// DataSource interface
type DataSource interface {
Get(id string) (string, error)
}The Base Component
Next, we implement the DatabaseSource. This simulates fetching data from a slow database.
type DatabaseSource struct {
// DB connection would go here
}
func (d *DatabaseSource) Get(id string) (string, error) {
// Simulate a slow database call
time.Sleep(100 * time.Millisecond)
return "User Data for " + id, nil
}Specialized Decorators
Now we can create decorators that wrap a DataSource and add specific behaviors.
Caching Decorator
The CachedSource checks an in-memory cache before calling the underlying source. The map here is not safe for concurrent use, so a real cache would need a mutex or sync.Map.
type CachedSource struct {
Source DataSource
Cache map[string]string
}
func (c *CachedSource) Get(id string) (string, error) {
// 1. Check cache
if val, ok := c.Cache[id]; ok {
log.Printf("Cache Hit for %s", id)
return val, nil
}
// 2. Call underlying source if not in cache
val, err := c.Source.Get(id)
if err != nil {
return "", err
}
// 3. Update cache
c.Cache[id] = val
return val, nil
}Logging Decorator
The LoggedSource wraps any DataSource and logs how long the operation took.
type LoggedSource struct {
Source DataSource
}
func (l *LoggedSource) Get(id string) (string, error) {
start := time.Now()
defer func() {
log.Printf("Get(%s) took %v", id, time.Since(start))
}()
return l.Source.Get(id)
}Leveraging the Decorator Pattern
The useful part is composition: the decorators stack in any order, so you pick exactly the behaviours you need.
Here is how we can assemble a repository that has both caching and logging:
func main() {
// 1. Create the base component (the real database fetcher)
db := &DatabaseSource{}
// 2. Wrap it with caching
// The cache needs to be initialized
cached := &CachedSource{
Source: db,
Cache: make(map[string]string),
}
// 3. Wrap that with logging
// The order matters! Here, we log the request *after* it hits the cache layer.
// This means we will see very fast times for cache hits, and slower times for misses.
finalSource := &LoggedSource{
Source: cached,
}
// 4. Use the decorated source in your application
// First call: Cache Miss -> Slow DB Call
fmt.Println("--- First Call ---")
val1, _ := finalSource.Get("123")
fmt.Println(val1)
// Second call: Cache Hit -> Fast Return
fmt.Println("\n--- Second Call ---")
val2, _ := finalSource.Get("123")
fmt.Println(val2)
}Separation of Concerns
By using decorators, our application logic doesn’t need to know if data is coming from the DB or the cache, or if it’s being logged. It simply relies on the DataSource interface.
Adding a RetrySource or MetricsSource later means writing one more wrapper, with no changes to the existing code.