Where Does Caching Belong in Clean Architecture?

cachingclean-architecturedotnet
Where Does Caching Belong in 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 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 spreads this way because each call site looks harmless on its own.

The Split: Mechanism vs. Decision

Split caching into two parts:

  • The mechanism is infrastructure. Redis clients, IMemoryCache, serialization, TTL bookkeeping, and key formatting are technical details you can replace without touching business behavior.
  • 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. A product catalog can be 5 minutes stale; an account balance cannot.

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.

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

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 requires.

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

Approach 1: The Caching Decorator

The decorator pattern 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:

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:

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 invalidation on write, 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

Registration with Scrutor is one Decorate call:

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

With 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 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. An in-memory store hands every caller the same instance, so a change one request makes to it leaks into other requests. A distributed cache means serialization instead. 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:

public interface ICachedQuery
{
    string CacheKey { get; }

    TimeSpan? Expiration { get; }
}

A query opts in by implementing it:

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 handles every cached query in the system:

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:

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

This is my default for query caching, because the staleness contract is on the query definition, where the person changing the use case will see it. The decision is visible in the application layer, and the mechanism stays behind ICacheService in infrastructure.

Invalidation Is Application Logic Too

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:

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.

Add two guardrails:

  • Always set a TTL, even with explicit invalidation. Expiration limits how long stale data lasts when an eviction path is missed.
  • On the query side, cache DTOs and read models, not entities. Responses and read models are flat and safe to serialize. Entity caching belongs in the repository decorator, centralized and paired with its invalidation.

I cover this decision, including where caching fits among the other cross-cutting concerns, in Pragmatic Clean Architecture.

Which Approach Should You Choose?

  • Entity-by-id lookups behind a repository: decorator. The policy is uniform, handlers never see it, and rollback is one line.
  • Query results and composed read models: pipeline behavior with ICachedQuery. The policy is declared by the use case that owns the staleness requirement.
  • Both in one codebase: fine. They share the same ICacheService, so there is one cache implementation in infrastructure.

Whichever you pick, run the flush test: if flushing the cache changes any business outcome (slower responses do not count), 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 layer never references the cache. If someone asks "what happens if we flush Redis", the answer is that requests get slower until the cache refills, 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.

  • The Application Layer in Clean Architecture

    The Application layer is where your use cases live: commands, queries, and the handlers that orchestrate your domain objects. A good handler reads like a table of contents (load, act, save). Here is how to structure the layer in .NET and keep business logic from leaking into it.

  • Authentication and Authorization in Clean Architecture

    Authentication is infrastructure and authorization is a business rule. Mixing the two up pulls ASP.NET Core into your domain. Here is where JWT validation, the ICurrentUserService abstraction, permission checks, and ownership rules each live, and how to keep them enforced beyond HTTP endpoints.

  • Background Jobs in Clean Architecture

    Background jobs in Clean Architecture are entry points, exactly like controllers. The job only schedules and triggers; the application layer does the work. Here is the structure, the scoped-service wiring that trips everyone up, and complete examples with BackgroundService and Quartz.

Browse all Clean Architecture guides →
Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.