DEV Community

Aleksander Wons
Aleksander Wons

Posted on

Caching: Various models

This is a copy of an old post on my private blog from November 2021.

*Working code with a remote environment for VSCode can be found here: https://github.com/awons/phpbyexample-cache

Cache on the side

At first, it sounds like a straightforward problem to solve, right? We need some data from a source that is expensive to query. We have that service that requires this data to do something with it. So we check if a specific key is in our cache. If it is - all good, we can grab the data and work with it. If not, we go to the source, grab the data, put it into our cache, and from now on, the data is accessible via the cache.
That is probably the most common solution seen in the wild. Let's say we have a service:

class Service
{
    private const CACHE_KEY_ALL_BOOKS = 'all_books';

    public function __construct(private Repository $repoistory, private Cache $cache)
    {
    }

    public function getAllBooks(): BooksCollection
    {
        $cacheKey = new CacheKey(self::CACHE_KEY_ALL_BOOKS);

        if (!$this->cache->has($cacheKey)) {
            $books = $this->repoistory->getBooks();
            $this->cache->put($cacheKey, $books);

            return $books;
        }

        $data = $this->cache->get($cacheKey);

        return BooksCollection::fromArray($data->asArray());
    }

    public function updateBook(Id $id, Title $title): void
    {
        $this->repoistory->updateTitle($id, $title);

        $this->cache->delete(new CacheKey(self::CACHE_KEY_ALL_BOOKS));
    }
}
Enter fullscreen mode Exit fullscreen mode

This service needs two dependencies: a repository and a cache. In our case, both the repository and the cache are interfaces:

interface Repository
{
    public function getBooks(): BooksCollection;

    public function updateTitle(Id $id, Title $title): void;
}

interface Cache
{
    public function has(CacheKey $cacheKey): bool;

    public function get(CacheKey $cacheKey): JsonObject;

    public function put(CacheKey $cacheKey, JsonFiable $value): void;

    public function delete(CacheKey $cacheKey): void;
}
Enter fullscreen mode Exit fullscreen mode

The concrete implementation is irrelevant. We only need to know how to talk to them.

While this solution is straightforward, it has one considerable downside: the service breaks SRP (Single Responsibility Principle). It has to communicate with the repository, with the cache, and know under which key should it look for a specific item. That may not necessarily be that big of a deal. In the end, everything is encapsulated in one class, so the details are not leaking outside of the service. And if this works for you - go for it. There is nothing particularly wrong or bad with this solution.

Here is an example of how we could interact with the service:

$logger = new ConsoleLogger();
$pdo = new MyPdo();

$cache = new APCUCache($logger);
$repository = new DbRepository($pdo, $logger);

$service = new Service($repository, $cache);

$logger->info('Looking for books');
$books = $service->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}

$logger->info('Looking for books');
$books = $service->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}

$logger->info('---------------------------------------------------');
$service->updateBook(new Id(1), new Title('Book title 1 - 9th Edition'));
$logger->info('---------------------------------------------------');

$logger->info('Looking for books');
$books = $service->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}

$logger->info('Looking for books');
$books = $service->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

Running the above code will produce the following output (timestamps removed for brevity):

console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] not found [] []
console.INFO: Loading books from DB [] []
console.INFO: Book [Book title 1 - 9th Edition] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Returning cache for [all_books] [] []
console.INFO: Book [Book title 1 - 9th Edition] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: --------------------------------------------------- [] []
console.INFO: Updating book [1] with new title [Book title 1 - 9th Edition] [] []
console.INFO: --------------------------------------------------- [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] not found [] []
console.INFO: Loading books from DB [] []
console.INFO: Book [Book title 1 - 9th Edition] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Returning cache for [all_books] [] []
console.INFO: Book [Book title 1 - 9th Edition] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
Enter fullscreen mode Exit fullscreen mode

As we can see in lines 2 and 3, books were not found in the cache, and we had to fall back to the repository. But then, in lines 6-8, we see that we had a hit, and the data got returned from the cache. Now, you may be wondering why we have the same log message saying a key has been found twice there β€” this is due to how I implemented the get() function in cache:

public function get(CacheKey $cacheKey): JsonObject
{
    if (!$this->has($cacheKey)) {
        throw new RuntimeException(sprintf('Book under key [%s] does not exist in cache', $cacheKey->asString()));
    }

    $result = false;
    $data = \apcu_fetch($cacheKey->asString(), $result);

    if (!$result) {
        throw new RuntimeException(sprintf('Error while retrieving cache key [%s]', $cacheKey->asString()));
    }

    $this->logger->info(sprintf('Returning cache for [%s]', $cacheKey->asString()));

    return new JsonObject($data);
}
Enter fullscreen mode Exit fullscreen mode

As we can see, this function makes an internal request to has() to make sure we have a key. That is not strictly necessary in this case. The apcu_fetch() function will anyhow return false if it couldn't find the data. I generally do this if the underlying library could throw an exception if it didn't find the key. That has a performance penalty and could be worked around by wrapping the call to the library in a try/cache block. In the end, it's a matter of personal preference and/or performance.

After line 13, where we update one of the books, we load the list again and see that the data was not found in the cache and needed to be loaded from the repository directly. Later, the cache was already populated, and we saw hits in it. Everything works as expected.

Read-through cache

Let's imagine that we have an application that is only reading data from a specific source. This application could use the approach we saw above - deal with the cache inside the service.

But there is another way of doing it. That is - to use a decorator pattern. We can wrap our repository in a layer that will do the heavy lifting related to cache. Let's have a look at how this would change our service:

class Service
{
    public function __construct(private ReadRepository $repository)
    {
    }

    public function getAllBooks(): BooksCollection
    {
        return $this->repository->getBooks();
    }
}
Enter fullscreen mode Exit fullscreen mode

The ReadRepository is an interface so that our service does not depend on anything concrete.

interface ReadRepository
{
    public function getBooks(): BooksCollection;
}
Enter fullscreen mode Exit fullscreen mode

Next, we need our repository that will read from the database. We will reuse a repository from the previous example to keep things more straightforward:

class DbReadRepository implements ReadRepository
{
    public function __construct(private Repository $coreRepository)
    {
    }

    public function getBooks(): BooksCollection
    {
        return $this->coreRepository->getBooks();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, all we need is a cache wrapper that will reference the DbReadRepository and handle all the details related to caching:

class CachedReadRepository implements ReadRepository
{
    private const KEY_ALL_BOOKS = 'all_books';

    public function __construct(private DbReadRepository $next, private Cache $cache)
    {
    }

    public function getBooks(): BooksCollection
    {
        $cacheKey = new CacheKey(self::KEY_ALL_BOOKS);
        if (!$this->cache->has($cacheKey)) {
            $books = $this->next->getBooks();
            $this->cache->put($cacheKey, $books);

            return $books;
        }

        return BooksCollection::fromArray($this->cache->get($cacheKey)->asArray());
    }
}
Enter fullscreen mode Exit fullscreen mode

As you may have noticed, both the DbReadRepository and the CachedReadRepository share the same interface. That makes it possible to use them interchangeably within our service. Our cached repository references the original database repository and a cache. It knows how to deal with cache keys and talk to the original repository. That makes it perfectly transparent to the outside world. From the perspective of our application, the code is straightforward:

$logger = new ConsoleLogger();
$pdo = new MyPdo();

$cache = new APCUCache($logger);
$dbRepository = new DbRepository($pdo, $logger);
$cachedReadRepository = new CachedReadRepository(new DbReadRepository($dbRepository), $cache);

$service = new Service($cachedReadRepository);

$logger->info('Looking for books');
$books = $service->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}

$logger->info('Looking for books');
$books = $service->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

Again, the usage of the service is explicit. Same as within the service itself. It just looks as if the service was calling a repository to get the data it needs. We could easily replace the cached repository with the database version. Due to the shared interface, everything would work as expected - except that every call to getBooks() would go to the database this time.

Let's quickly have a look at how the output of using this kind of approach would look like (timestamps removed for brevity):

console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] not found [] []
console.INFO: Loading books from DB [] []
console.INFO: Book [Book title 1] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Returning cache for [all_books] [] []
console.INFO: Book [Book title 1] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
Enter fullscreen mode Exit fullscreen mode

As expected, the first call to get the books does not find anything in the cache, so that it falls back to the database repository. But the next call already considers the key in the cache, so it simply returns the data without going to the database.

Write-through cache

The previous example was making read-only operations. There obviously must be another part of the code that deals with writing operations. That part can as well use the same mechanism to deal with a cache. Let's start by defining an interface for our repository:

interface WriteRepository
{
    public function updateTitle(Id $id, Title $title): void;
}
Enter fullscreen mode Exit fullscreen mode

And then implement the actual DB access reusing a class from the first example:

class DbWriteRepository implements WriteRepository
{
    public function __construct(private Repository $coreRepository)
    {
    }

    public function updateTitle(Id $id, Title $title): void
    {
        $this->coreRepository->updateTitle($id, $title);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the end, we need to create our decorator that will have the same interface and will forward calls to the database repository:

class CachedWriteRepository implements WriteRepository
{
    private const KEY_ALL_BOOKS = 'all_books';

    public function __construct(private WriteRepository $next, private Cache $cache)
    {

    }

    public function updateTitle(Id $id, Title $title): void
    {
        $cacheKey = new CacheKey(self::KEY_ALL_BOOKS);
        $this->next->updateTitle($id, $title);
        $this->cache->delete($cacheKey);
    }
}
Enter fullscreen mode Exit fullscreen mode

The implementation is straightforward. We first update the data in the database and then clear the cache (so that we know that it makes sense).

To see how it works, let's run the example. It uses both the read-through and the right-through variant:

$logger = new ConsoleLogger();
$pdo = new MyPdo();

$cache = new APCUCache($logger);
$dbRepository = new DbRepository($pdo, $logger);

$cachedReadRepository = new CachedReadRepository(new DbReadRepository($dbRepository), $cache);
$readService = new ReadService($cachedReadRepository);

$logger->info('Looking for books');
$books = $readService->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}

$cachedWriteRepository = new CachedWriteRepository(new DbWriteRepository($dbRepository), $cache);
$writeService = new WriteService($cachedWriteRepository);


$logger->info('------------------------------------------------');
$writeService->updateBook(new Id(1), new Title('Book title 1 - 9th Edition'));
$logger->info('------------------------------------------------');

$logger->info('Looking for books');
$books = $readService->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

Running this will yield an output similar to this one (again, timestamps removed for brevity):

console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] not found [] []
console.INFO: Loading books from DB [] []
console.INFO: Book [Book title 1] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Returning cache for [all_books] [] []
console.INFO: Book [Book title 1] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: ------------------------------------------------- [] []
console.INFO: Updating book [1] with new title [Book title 1 - 9th Edition] [] []
console.INFO: ------------------------------------------------- [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] not found [] []
console.INFO: Loading books from DB [] []
console.INFO: Book [Book title 1 - 9th Edition] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Returning cache for [all_books] [] []
console.INFO: Book [Book title 1 - 9th Edition] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
Enter fullscreen mode Exit fullscreen mode

The read-through and right-through caches are complimentary. That model is useful when we have two different sets of services where one is responsible for writing and another for reading. We could use this model in one application where we have a separation in repositories - one for reading and one for writing.

The only challenge in splitting them into two is how to handle cache keys. You could do it by convention and document a rule by which keys are generated. Alternatively, you could have a shared repository dealing with it (it could be an overkill, though). If you have a separation within one application: it is only a matter of using something for a shared namespace.

Write-read-through

The previous two examples assume that we will have a separation between the writing part and the reading part. That might not always be necessary, especially in simpler applications. When we have everything in one place, we can have one repository doing both reading and writing. In that case, we still use the same decorator and wrap just one repository.

As always, we need to start with an interface. This time it will have two methods that need to be implemented:

interface WriteReadRepository
{
    public function updateTitle(Id $id, Title $title): void;

    public function getBooks(): BooksCollection;
}
Enter fullscreen mode Exit fullscreen mode

Then we need a database-based repository that will implement the above interface:

class DbWriteReadRepository implements WriteReadRepository
{
    public function __construct(private DbRepository $coreRepository)
    {
    }

    public function updateTitle(Id $id, Title $title): void
    {
        $this->coreRepository->updateTitle($id, $title);
    }

    public function getBooks(): BooksCollection
    {
        return $this->coreRepository->getBooks();
    }
}
Enter fullscreen mode Exit fullscreen mode

At the end, we wrap the database repository using a decorator pattern:

class CachedWriteReadRepository implements WriteReadRepository
{
    private const KEY_ALL_BOOKS = 'all_books';

    public function __construct(private WriteReadRepository $next, private Cache $cache)
    {
    }

    public function updateTitle(Id $id, Title $title): void
    {
        $cacheKey = new CacheKey(self::KEY_ALL_BOOKS);
        $this->next->updateTitle($id, $title);
        $this->cache->delete($cacheKey);
    }

    public function getBooks(): BooksCollection
    {
        $cacheKey = new CacheKey(self::KEY_ALL_BOOKS);
        if (!$this->cache->has($cacheKey)) {
            $books = $this->next->getBooks();
            $this->cache->put($cacheKey, $books);

            return $books;
        }

        return BooksCollection::fromArray($this->cache->get($cacheKey)->asArray());
    }
}
Enter fullscreen mode Exit fullscreen mode

Same as in the previous examples, only the decorator is aware of the caching. Both the database repository and the service are blissfully unaware that this caching layer even exists.

This is how we can use this implementation:

$logger = new ConsoleLogger();
$pdo = new MyPdo();

$cache = new APCUCache($logger);
$dbRepository = new DbRepository($pdo, $logger);

$cachedRepository = new CachedWriteReadRepository(
    new DbWriteReadRepository($dbRepository),
    $cache
);

$service = new Service($cachedRepository);

$logger->info('Looking for books');
$books = $service->getAllBooks();

/** @var Book $book */
foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}

$logger->info('Looking for books');
$books = $service->getAllBooks();

foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}

$logger->info('---------------------------------------------------');
$service->updateBook(new Id(1), new Title('Book title 1 - 9th Edition'));
$logger->info('---------------------------------------------------');

foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}

$logger->info('Looking for books');
$books = $service->getAllBooks();

foreach ($books as $book) {
    $logger->info(
        sprintf(
            'Book [%s] by [%s]',
            $book->getTitle()->asString(),
            $book->getAuthor()->asString()
        )
    );
}
Enter fullscreen mode Exit fullscreen mode

Running this code will yield the following log messages which prove that everything works as expected:

console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] not found [] []
console.INFO: Loading books from DB [] []
console.INFO: Book [Book title 1] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Cache key [all_books] found [] []
console.INFO: Returning cache for [all_books] [] []
console.INFO: Book [Book title 1] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: ------------------------------------------------- [] []
console.INFO: Updating book [1] with new title [Book title 1 - 9th Edition] [] []
console.INFO: ------------------------------------------------- [] []
console.INFO: Book [Book title 1] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
console.INFO: Looking for books [] []
console.INFO: Cache key [all_books] not found [] []
console.INFO: Loading books from DB [] []
console.INFO: Book [Book title 1 - 9th Edition] by [Jane Smith] [] []
console.INFO: Book [Book title 2] by [John Smith] [] []
Enter fullscreen mode Exit fullscreen mode

Summary
We saw the 4 most common ways of dealing with cache and how we could implement them in PHP:
on-the-side; when it's the service that deals with both the original data source and the cache
read-through; when a read-only repository is wrapped with a caching layer using a decorator pattern to hide the fact that a cache is used
write-through; when a write-only repository is wrapped with a caching layer to hide the fact that it clears cache keys when updating/writing data
write-read-through; is a combination of the previous two, where both read and write are handled using a wrapper over a repository

You can find the whole working code on GitHub: https://github.com/awons/phpbyexample-cache. The repository also contains a .devenvironment you can use in VSCode.

Top comments (0)