Enhancing Data Access with the Decorator Pattern in Go
The decorator pattern is a structural design pattern that allows augmenting an object’s behaviour without altering its core structure. In Go, this pattern is especially powerful when separating concerns like caching, logging, or instrumentation from your core business logic.
In Go, since there is no direct support for classes and inheritance as in some other languages like Java or C#, the decorator pattern is typically implemented using interfaces and embedding. This article will explore how to implement the decorator pattern in Go and how to leverage it to create a robust data access layer with stackable functionalities.
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
DataSource <|.. CachedSource : Decorates
}
class LoggedSource {
- Source: DataSource
+ Get(id: string): string
DataSource <|.. LoggedSource : Decorates
}
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.
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 true power of this pattern comes from composition. We can stack these decorators in any order to build a data pipeline with exactly the features we 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.
This approach enables a modular and flexible architecture. You can easily add new behaviors (like a RetrySource or MetricsSource) without modifying the existing business logic or the base database implementation.