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

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.