Enhancing Data Access with the Decorator Pattern in Go

Enhancing Data Access with the Decorator Pattern in Go

December 3, 2023
Matryoshka Dolls in various sizes

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.