Most of the requests received by our services are queries, and most of these queries are the same. For each request we need to query the database and transform the data for presentation, a lot of work which could be avoided by caching the response. If you developed your service with something resembling CQS, or even a single dispatcher that handles both your queries and commands, you are all set to implement HTTP caching.

Before diving into response caching, let's take a moment to review the gist of a query dispatcher—a command dispatcher is kind of the same except it's not supposed to return a result.

The basic idea of a query dispatcher

A query dispatcher matches a query object with a query handler. The query object contains all the necessary information to for its handler to perform the query, using a repository or a finder. The handler returns an entity or a collection of entities.

The query object

In order for the caching to work properly, it is crucial for the application to have no state. At least, no state that may alter the result of a query. Everything required for the query must be defined inside the query object, any external factor would corrupt the cache.

The following example demonstrates the implementation of a ShowRecipe query, that would be used to fetch a recipe with a matching identifier:

<?php

// ...

final class ShowRecipe
{
    /**
     * @var string
     */
    public $id;

    public function __construct(string $id)
    {
        $this->id = $id;
    }
}

Caching responses

Caching responses requires the interaction of multiple components: an action handling the HTTP request, a query dispatcher handling the query, a method turning a query into a response, a cache layer, and of course a cache for the responses.

Implementing the 'show' action

The 'show' action uses a ShowRecipe query to fetch one record. It uses the respondToCommand() method defined by its parent class to dispatch the query and return a Response instance, that's where the magic happens.

<?php

// ...

final class ShowAction implements Action
{
    public function __invoke(Request $request): Response
    {
        // ...

        return $this->respondToQuery(
            new ShowMenu($id)
        );
    }
}

Implementing the cache layer

The cache layer is implemented as a decorator of QueryResponder. The respondToQuery() method accepts a query object and return a Response. So, before the method is delegated to the decorated responder we try to get a response from the cache. And after the call is delegated we update the cache with a fresh response.

Here's an excerpt of the implementation:

<?php

// ...

final class CachedQueryResponder implements QueryResponder
{
    // ...

    /**
     * @inheritDoc
     */
    public function respondToQuery(object $query): Response
    {
        $shouldHandleCaching = $this->shouldHandleCaching();

        if ($shouldHandleCaching) {
            $response = $this->retrieveResponse($query);

            if ($response) {
                return $response;
            }
        }

        $response = $this->decorated->respondToQuery($query);

        if ($shouldHandleCaching) {
            $this->storeResponse($query, $response);
        }

        return $response;
    }

    // ...
}

Keeping the cache consistent

The cache is only consistent if executing a same query without it would yield the same result. It might be acceptable in some situation to serve stale results, but that's not what we're aiming for here. When a contributor updates a recipe, their changes should be reflected immediately.

Just like we use a query dispatcher to fetch data, we use a command dispatcher to create/update/delete data. So, in order to keep our cache fresh, we have to invalidate the corresponding responses. For instance, when a recipe is created responses cached after ListRecipes must be invalidated.

We use another decorator, for the CommandResponder this time:

<?php

// ...

final class CacheInvalidatingCommandResponder implements CommandResponder
{
    /**
     * @inheritDoc
     */
    public function respondToCommand(object $command): Response
    {
        $response = $this->decorated->respondToCommand($command);

        $this->invalidate($command, $response);

        return $response;
    }
}

Now that we've seen how to retrieve/store/remove cached responses, let's see what this mysterious response cache is made of.

The response cache

The response cache is used to retrieve, store, and invalidate responses. We use Symfony's tag aware cache adapter because tagging is crucial for managing our cache. You see every response stored is tagged to be easily invalidated. For instance both the ShowRecipe and ListRecipes queries have recipe tag, so that when a recipe is updated corresponding cached responses are invalidated. They also could use ingredient, so that when ingredients used in recipes are updated the cached responses are also invalidated. The granularity of the cache is up to you.

Cache tags are enumerated in an interface (I'm actually using more specific tags like RECIPE_SINGLE and RECIPE_MULTIPLE, but I'm keeping this example simple):

<?php

interface CacheTagEnum
{
    public const INGREDIENT = 'ingredient';
    public const RECIPE = 'recipe';
    public const RESPONSE = 'response';
}

I also define an interface that can be implemented by both query and command classes to specify the tags to use:

<?php

// ...

interface HasCacheTags
{
    /**
     * @return string[] The cache tags that should be invalidated after this command.
     */
    public function getCacheTags(): array;
}
<?php

// ...

class ShowRecipe implements HasCacheTags
{
    // ...

    public function getCacheTags(): array
    {
        return [ CacheTagEnum::RECIPE, CacheTagEnum::INGREDIENT ];
    }
}
<?php

// ...

class UpdateRecipe implements HasCacheTags
{
    // ...

    public function getCacheTags(): array
    {
        return [ CacheTagEnum::RECIPE ];
    }
}

A response cache implementation

Here is an implementation of the response cache. An hash of the query object is used as cache key, and all cached items are tagged with the same tag so it's easy to invalidate them all at once.

<?php

// ...

use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\HttpFoundation\Response;

class BasicResponseCache implements ResponseCache
{
    private const CACHE_TAG = CacheTagEnum::RESPONSE;
    private const KEY_PREFIX = CacheTagEnum::RESPONSE . '.';

    /**
     * @var TagAwareAdapterInterface
     */
    private $cache;

    /**
     * @var TagResolver
     */
    private $tagResolver;

    public function __construct(TagAwareAdapterInterface $cache, TagResolver $tagResolver)
    {
        $this->cache = $cache;
        $this->tagResolver = $tagResolver;
    }

    public function retrieve(object $query): ?Response
    {
        $item = $this->getCacheItem($query);

        return $item->isHit() ? $item->get() : null;
    }

    public function store(object $query, Response $response): void
    {
        $item = $this->getCacheItem($query);
        $item->tag(array_merge([ self::CACHE_TAG ], $this->resolveTags($query)));
        $item->set($response);

        $this->cache->save($item);
    }

    public function remove(object $command): bool
    {
        $tags = $this->resolveTags($command);

        if (!$tags) {
            return false;
        }

        return $this->cache->invalidateTags($tags);
    }

    public function clear(): void
    {
        $this->cache->invalidateTags([ self::CACHE_TAG ]);
    }

    private function getCacheItem(object $query): CacheItem
    {
        $key = $this->makeKey($query);
        $item = $this->cache->getItem($key);

        return $item;
    }

    private function makeKey(object $query): string
    {
        return self::KEY_PREFIX . $this->hashQuery($query);
    }

    private function hashQuery(object $query): string
    {
        return sha1(serialize($query));
    }

    private function resolveTags(object $queryOrCommand): array
    {
        return ($this->tagResolver)($queryOrCommand);
    }
}

Payoff!

And now we're serving ~90% of the requests from cache. Yay!

Cache graph