# Where Does Caching Belong in Clean Architecture?

> Caching is infrastructure, but the decision to cache is application-level. Get that split wrong and cache concerns leak into your use cases, or worse, into your domain. Here are two clean approaches: a decorator over your repositories and a pipeline behavior driven by the use case itself.

Published: 2026-08-11. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/caching-clean-architecture

Caching in Clean Architecture splits in two: the mechanism (Redis, `IMemoryCache`, serialization) belongs in the infrastructure layer, while the decision (what to cache, how stale, when to evict) belongs in the application layer.
The domain layer never knows caching exists.
Here are two clean ways to implement that split: a repository decorator and a pipeline behavior driven by the use case itself.

Someone on your team adds `IMemoryCache` to a query handler.
A week later a `DbContext`-shaped cache call shows up in a domain service, and a month later you cannot answer "what happens if we flush Redis" without reading half the codebase.
Caching has a way of leaking everywhere, because it feels harmless at every individual call site.

## The Split: Mechanism vs. Decision

Here is the principle that resolves every "where does this go" debate about caching:

- **The mechanism is infrastructure.** Redis clients, `IMemoryCache`, serialization, TTL bookkeeping, key formatting. All of it is a technical detail, replaceable without touching business behavior. It lives in the infrastructure layer.
- **The decision is application-level.** *What* is worth caching, *how stale* it may be, and *when* it must be invalidated are things only the use case knows. Product catalog: 5 minutes stale is fine. Account balance: absolutely not. That knowledge belongs to the application layer.

And one hard rule: **the domain layer never knows caching exists.**
No entity, value object, or domain service should reference a cache, and no invariant may depend on cached state.
Caching is a performance optimization, and the domain models business rules, not performance.

So the application layer expresses caching *intent* through an abstraction it owns, and infrastructure provides the implementation.
The abstraction is small:

```csharp
public interface ICacheService
{
    Task<T?> GetAsync<T>(string key, CancellationToken ct = default);

    Task SetAsync<T>(
        string key,
        T value,
        TimeSpan? expiration = null,
        CancellationToken ct = default);

    Task RemoveAsync(string key, CancellationToken ct = default);
}
```

The interface is defined in the application layer.
Infrastructure implements it with **Redis**, `HybridCache`, or an in-memory store, and the dependency arrow points inward, exactly as the [**dependency rule**](https://milanjovanovic.tech/blog/dependency-rule-clean-architecture) requires.

With the split established, there are two clean places to apply it.

## Approach 1: The Caching Decorator

The [**decorator pattern**](https://milanjovanovic.tech/blog/decorator-pattern-in-asp-net-core) is the classic answer, and it is still the cleanest when you want caching to be invisible to consumers.

Say you have a repository interface in the application layer:

```csharp
public interface IProductRepository
{
    Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default);

    Task UpdateAsync(Product product, CancellationToken ct = default);
}
```

The caching decorator wraps the real implementation:

```csharp
public sealed class CachedProductRepository(
    IProductRepository inner,
    ICacheService cache) : IProductRepository
{
    private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5);

    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default)
    {
        string key = $"products:{id}";

        Product? cached = await cache.GetAsync<Product>(key, ct);
        if (cached is not null)
        {
            return cached;
        }

        Product? product = await inner.GetByIdAsync(id, ct);

        if (product is not null)
        {
            await cache.SetAsync(key, product, Expiration, ct);
        }

        return product;
    }

    public async Task UpdateAsync(Product product, CancellationToken ct = default)
    {
        await inner.UpdateAsync(product, ct);

        await cache.RemoveAsync($"products:{product.Id}", ct);
    }
}
```

Notice `UpdateAsync`: the decorator is also the natural home for **write-through invalidation**, because it sees every mutation that goes through the interface.

![Request flow through a caching decorator: on a cache hit it returns the cached value, and on a miss it calls the inner repository, reads the database, stores the result with a TTL, then returns it](https://milanjovanovic.tech/blogs/articles/caching-clean-architecture/caching-decorator-flow.png)

Registration with [**Scrutor**](https://milanjovanovic.tech/blog/improving-aspnetcore-dependency-injection-with-scrutor) is one `Decorate` call:

```csharp
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
```

The strengths of this approach: use case handlers do not change at all, caching is centralized per aggregate, and removing it is a one-line rollback.
The weakness: the TTL decision now lives in infrastructure, one step removed from the use cases that actually know the staleness requirements.
For entity-by-id caching that is usually fine, because the policy is uniform.

One caveat: the decorator caches a domain entity, which costs nothing with an in-memory store but means serialization in a distributed cache.
Encapsulated entities with private setters often do not round-trip through JSON without extra serializer configuration, so verify that before pointing this at Redis.

## Approach 2: The Use Case Declares Its Caching

For query results (the read side of CQRS), I prefer the use case itself to declare its caching policy.
The query says what it needs; a pipeline behavior does the work.

Define a marker interface in the application layer:

```csharp
public interface ICachedQuery
{
    string CacheKey { get; }

    TimeSpan? Expiration { get; }
}
```

A query opts in by implementing it:

```csharp
public sealed record GetProductCatalogQuery(int Page, int PageSize)
    : IRequest<ProductCatalogResponse>, ICachedQuery
{
    public string CacheKey => $"catalog:page-{Page}:size-{PageSize}";

    public TimeSpan? Expiration => TimeSpan.FromMinutes(5);
}
```

And a single [**pipeline behavior**](https://milanjovanovic.tech/blog/mediatr-pipeline-behaviors) handles every cached query in the system:

```csharp
public sealed class QueryCachingBehavior<TRequest, TResponse>(
    ICacheService cache,
    ILogger<QueryCachingBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICachedQuery
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        TResponse? cached = await cache.GetAsync<TResponse>(request.CacheKey, ct);

        if (cached is not null)
        {
            logger.LogDebug("Cache hit: {CacheKey}", request.CacheKey);

            return cached;
        }

        TResponse response = await next();

        await cache.SetAsync(request.CacheKey, response, request.Expiration, ct);

        return response;
    }
}
```

Registered once:

```csharp
builder.Services.AddMediatR(config =>
{
    config.RegisterServicesFromAssembly(typeof(ICachedQuery).Assembly);
    config.AddOpenBehavior(typeof(QueryCachingBehavior<,>));
});
```

This is my default for query caching, for one reason: **the staleness contract sits on the query definition**, right where the person changing the use case will see it.
The decision is application-level, visibly, while the mechanism stays behind `ICacheService` in infrastructure.
Exactly the split we wanted.

## Invalidation Is Application Logic Too

The part most articles skip: who evicts?

Knowing that `UpdateProductCommand` invalidates `products:{id}` and every `catalog:*` page is pure use-case knowledge.
So invalidation belongs in the application layer, and the cleanest trigger is the same event flow you already have.

If your aggregates raise domain events, a cache-eviction handler is a natural subscriber:

```csharp
public sealed class ProductUpdatedCacheEvictionHandler(ICacheService cache)
    : INotificationHandler<ProductUpdatedDomainEvent>
{
    public async Task Handle(ProductUpdatedDomainEvent domainEvent, CancellationToken ct)
    {
        await cache.RemoveAsync($"products:{domainEvent.ProductId}", ct);
        await cache.RemoveByPrefixAsync("catalog:", ct);
    }
}
```

`RemoveByPrefixAsync` is an extra method you add to `ICacheService` for view-level eviction; the Redis implementation backs it with key scans or key tagging.

One handler owns the mapping from "product changed" to "these views are stale".
When invalidation logic is scattered across command handlers, one forgotten eviction ships a stale-data bug; centralizing it per event keeps the mapping auditable.
For the eviction techniques themselves (prefix removal, versioned keys, TTL fallbacks), see **cache invalidation strategies**.

Two guardrails to keep this honest:

- **Always set a TTL, even with explicit invalidation.** Expiration is your safety net when an eviction path is missed.
- **On the query side, cache DTOs and read models, not entities.** Responses and read models are stable, flat, and safe to serialize. Entity caching belongs in the repository decorator, centralized and paired with its invalidation, not scattered across handlers.

I cover this decision, including where caching fits among the other cross-cutting concerns, in [**Pragmatic Clean Architecture**](https://milanjovanovic.tech/pragmatic-clean-architecture).

## Which Approach Should You Choose?

Practical guidance:

- **Entity-by-id lookups behind a repository**: decorator. Uniform policy, invisible to handlers, easy rollback.
- **Query results and composed read models**: pipeline behavior with `ICachedQuery`. Policy lives with the use case that owns the staleness requirement.
- **Both in one codebase**: completely fine, and common. They share the same `ICacheService`, so infrastructure stays singular.

Whichever you pick, run the flush test: if flushing the cache changes any business outcome (not latency, outcome), caching has leaked past infrastructure and needs to be pushed back out.

## Summary

The mechanism (Redis, memory, serialization, TTLs) is infrastructure behind an application-owned `ICacheService`.
The decision (what to cache, how stale, when to evict) is application knowledge, expressed either as a decorator policy per repository or as a declaration on the query itself.

The domain never finds out.
And when someone asks "what happens if we flush Redis", the answer should be one sentence: everything gets slower for a minute, and nothing else changes.

## Frequently asked questions

### Which layer should caching go in with Clean Architecture?

The caching implementation (Redis, IMemoryCache, serialization) belongs in the infrastructure layer. The decision about what to cache and for how long is application-level knowledge, expressed through an abstraction the application layer owns.

### Should the domain layer know about caching?

No. Caching is a performance concern, not a business rule. Domain entities and domain services should never reference a cache, and no invariant should depend on cached state.

### How do I add caching without changing my use case handlers?

Use the decorator pattern. A caching decorator wraps the repository or query handler interface, checks the cache before delegating, and stores the result after. The inner implementation and its consumers stay unchanged.

### Where does cache invalidation logic belong?

In the application layer, because knowing which data changed and which cached views it affects is use-case knowledge. Command handlers or event handlers evict or update the relevant keys through the same cache abstraction.
