Where Does the Cache Go?

Estimated read time 7 min read

How a Simple Feature Became a Lesson in Software Architecture

Caching looks simple. Store the result, reuse it later, skip the API call. The pattern is common, well documented, yet still easy to underestimate.

When I tried to add caching to a real codebase, the implementation was not the hard part. Deciding where it belonged was. The real question was not how to implement it or which data structure to use, but where in the system that responsibility should live, and which component should own it.

A Working System Meets a New Requirement

The project was a command-line Pokedex application in Go language. A client handled HTTP communication, commands triggered requests, and responses were parsed and displayed. The system was simple, functional, and predictable.

Then I needed to add caching.

The First Instinct: Extend the Client

The most immediate solution was to extend the existing API client:

type Client struct {
    httpClient *http.Client
    cache      map[string]interface{}
}

At first, this seemed reasonable. The client could check whether a response had already been stored and return it directly if so. The behavior was correct, and the feature worked, but something about the design felt off.

The client was no longer responsible only for retrieving data. It was now also responsible for deciding when to reuse that data, how to store it, and how long it should remain valid. Caching may be closely related to data retrieval, but that is not the same as saying both concerns belong in the same place.

Once caching lives inside the client, the client also inherits everything that comes with it: expiration, invalidation, concurrency, and policy. The issue is not that this approach fails. The issue is that it blurs ownership.

Reframing the Question

The breakthrough came when I stopped asking, “Where should I put the cache?” and started asking, “Which component should decide whether data needs to be fetched?”

That distinction matters. Fetching data and deciding whether to fetch data are not the same job. One component performs the work. The other decides whether the work is needed.

When both responsibilities live in the same place, the system becomes harder to reason about. When they are separated, the design becomes easier to understand.

Introducing a Layer: From Modification to Composition

The right structure was already present in the problem. I had to stop modifying the client and start composing around it.

type Client struct {
    httpClient *http.Client
    baseURL    string
}

type CachedClient struct {
    client PokeAPIClient
    cache  Cache
    ttl    time.Duration
}

The original client stays focused on one concern: retrieving data from the API. The CachedClient wraps it and adds a different concern: deciding whether that retrieval is needed. Each layer adds one capability without changing what sits underneath. New concerns are added around the client, not pushed into it. This is sometimes called the decorator pattern, but the label matters less than the principle.

Behavior should be composed from clear responsibilities, not packed into one component.

What the Flow Looks Like

With this structure, the flow is straightforward:

Command -> CachedClient
            |
       [Cache lookup]
         /       \
      hit         miss
       |            |
    return      Client -> API
                     |
                 store result
                     |
                  return

Each component answers a different question:

The CachedClient asks: Do we need to fetch this data?

The client asks: How do we fetch this data?

That separation makes the code easier to understand and easier to change. Instead of hiding multiple decisions inside one component, the system becomes a clear sequence of choices and actions.

When Structure Solves More Than One Problem

Caching was not the only problem this design solved. I also needed to avoid real API calls during tests, which meant being able to swap the production client for a mock.

Once the client was expressed as an interface, that became straightforward. The same contract could be implemented by a real client in production, a cached client for optimized behavior, and a mock client in tests, without forcing changes elsewhere in the system.

What began as a solution for caching became a foundation for testability.

Good design often works this way. A well-placed abstraction rarely solves only the problem in front of you. It also creates room for problems you have not encountered yet.

Trade-offs and Context

None of this is universally correct. In a small script or a prototype that will never grow, putting the cache directly inside the client can be a perfectly reasonable choice. It means fewer files, less indirection, and faster delivery.

The layered approach has real costs. It introduces more types, more indirection, and more design decisions.

What it gives back is flexibility. When responsibilities are separated, behaviors like logging, retries, rate limiting, or circuit breaking can be added without changing code that already works. The trade-off is not simplicity versus complexity. It is immediate convenience versus long-term clarity.

From Features to Ownership

This changed how I think about code.

Before, I focused mostly on features: what the code needed to do. Now I think more about ownership: where a behavior should live, why it belongs there, and what that choice will affect later.

Who is responsible for this behavior? Does it belong in an existing component or a new one? What future changes will this decision make easier or harder?

These questions are not specific to caching. They show up whenever a feature starts shaping the structure of a system. Asking them deliberately is part of writing code that stays understandable over time.

“Where does the cache go?” is not really a question about caching.

It is a question about ownership. And in software, ownership is never a small question.

Every piece of behavior in a system must live somewhere. If that decision is not made deliberately, it will be made by default. And default decisions accumulate.

Good architecture is not about predicting every future requirement. It is about making sure that when new behavior appears, there is a clear place for it to belong.

Ask that question consistently, and even small features become chances to build something that stays maintainable.

About the Author

Camille Onoda

Freelance Backend Developer (Python, Go) | Technical Translator and Reviewer

Japan, 2026

Follow / connect with the author on LinkedIn:

https://jp.linkedin.com/in/camilleonoda

Camille Onoda is a freelance backend developer working with Python and Go, with a background in translation, review, and cross-cultural communication. Her work sits at the intersection of clean backend design, technical learning, and thoughtful problem-solving across languages and systems.

Subscribe to our newsletter!

Camille Onoda https://www.linkedin.com/in/camilleonoda/

Camille Onoda is a backend developer in training working with Python, Go, and AWS. Coming from a background in translation, she brings clarity, structure, and cross-cultural communication into her engineering work. She is focused on learning backend fundamentals through practical, self-built projects. Experienced in remote collaboration and version control, she values reliability, clean design, and understanding how systems really work under the surface. She speaks French, English and Japanese.

You May Also Like

More From Author

+ There are no comments

Add yours