Caching is an orthogonal responsibility

I’m writing this post to share something I’ve come to realize about caching in terms of software design. This might seem pretty obvious to some, and it certainly feels this way to me now, but I decided to write it anyhow.

Caching an expensive operation is a common strategy to squeeze out performance from a program. From a code organisation perspective, it’s easy to pollute the original code with caching instructions. I’ve found that caching is always an orthogonal responsibility when comparing it to the main responsibility of the module and using composition we can design better components.

(If you are unfamiliar with the term, I’m writing a post on what orthogonality means in software design. Promise i’ll come soon.)

Let’s compare two simple strategies for adding caching to an existing code.

type Client struct {
}

func (c *Client) Fetch(s string) string {
return expensiveOperation()
}

First, the naive version.

type Client struct {
cache map[string][string]
}

func (c *Client) Fetch(s string) string {
if cached, ok := cache[s]; !ok {
cache[s] = expensiveOperation()
}
return cache[s]
}

Now, let’s compare with a version where we introduce an interface and add caching capabilities trough composition.

type Fetcher interface {
Fetch() string
}

type Client struct {
}

func (c *Client) Fetch(s) string {
expensiveOperation()
}

type CachingClient struct {
cache map[string][string]
client Fetcher
}

func (c *CachingClient) Fetch(s) string {
if cached, ok := cache[s]; !ok {
cache[s] = c.Client.Fetch()
}
return cache[s]
}

Comparing the two versions, we immediately see some obvious disadvantages:

  • More code
  • Two additional types
  • Necessity for boilerplate code to initialise the complete functional client

Now let’s analyse what we gained:

The introduction of an interface type adds flexibility to system. The upper levels of the code can depend on the interface instead of a specific implementation. We take advantage of this flexibility to introduce a Fetcher that decorates any other Fetcher, not just this specific one. Because the two are now independent, they are now compose-able and therefore easier to reuse and to test. To sum it up:

  • Flexibility
  • Inversion of dependencies
  • Compose-ability
  • Simplification of testing

The tradeoff is pretty clear and the final decision depends on your specific context.

While this post focus on caching, we can apply the same pattern on a number of different scenarios to obtain flexible and compose-able modules. We just have to identify the orthogonal responsibility.

--

--