Mastering Cache Invalidation in CQRS Architectures

Implementing robust caching strategies using decorators and domain events

Posted by Hüseyin Sekmenoğlu on January 01, 2026 Architecture & Patterns

🏗️ The CQRS Caching Challenge

In modern software architecture, Command Query Responsibility Segregation (CQRS) has become a standard pattern. We typically separate our concerns into classes implementing ICommand with their respective ICommandHandler and IQuery with IQueryHandler.

This separation provides clarity but introduces specific challenges when performance optimization is required. Common scenarios involve frequently accessed data such as dropdown lists or configuration settings. While the database might handle these requests reasonably well, implementing a caching layer is often necessary to reduce load and latency.


🎨 The Decorator Pattern Approach

The most elegant way to introduce caching in a CQRS system is via the Decorator pattern. Rather than cluttering business logic with caching code, we can wrap our query handlers.

First, we define an interface for queries that require caching:

public interface ICachedQuery {
    String CacheKey { get; }
    int CacheDurationMinutes { get; }
}

Next, we implement a decorator that intercepts the query. It checks the cache provider before deciding whether to delegate execution to the underlying handler:

public class CachedQueryHandlerDecorator<TQuery, TResult> 
    : IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    private IQueryHandler<TQuery, TResult> decorated;
    private readonly ICacheProvider cacheProvider;

    public CachedQueryHandlerDecorator(IQueryHandler<TQuery, TResult> decorated, 
        ICacheProvider cacheProvider) {
        this.decorated = decorated;
        this.cacheProvider = cacheProvider;
    }

    public TResult Handle(TQuery query) {
        var cachedQuery = query as ICachedQuery;
        if (cachedQuery == null)
            return decorated.Handle(query);

        var cachedResult = (TResult)cacheProvider.Get(cachedQuery.CacheKey);

        if (cachedResult == null)
        {
            cachedResult = decorated.Handle(query);
            cacheProvider.Set(cachedQuery.CacheKey, cachedResult, 
                cachedQuery.CacheDurationMinutes);
        }

        return cachedResult;
    }
}

🧩 The Invalidation Conundrum

The difficult part of caching is rarely the storage but the invalidation. In a complex domain, a single command (such as modifying a person record) might render multiple read models obsolete.

Developers often debate between using interfaces or attributes for defining cache keys. While interfaces allow for dynamic key generation (e.g. including an entity ID), they do not solve the decoupling issue. If a command needs to know exactly which cache keys to invalidate, we introduce tight coupling between the write side and the read side.

Potential but flawed solutions include:

  1. Entity-Based Keys: Storing cache keys inside the entity class and extracting them during updates. This feels "hacky" and leaks infrastructure concerns into the domain.

  2. Command-Based Invalidation: Having commands return a list of keys to invalidate. This makes key generation messy and prone to errors if a developer forgets to update the invalidation logic for a new query.

  3. Short Lifetimes: Relying solely on short cache durations. This avoids invalidation logic but may not provide sufficient performance benefits.


📡 The Solution: Domain Events

A superior approach utilizes Domain Events. This decouples the command execution from the cache invalidation logic.

The command handler performs its operation and publishes an event. It does not need to know that a cache exists or that it needs clearing.

public class AddCityCommandHandler : ICommandHandler<AddCityCommand>
{
    private readonly IRepository<City> cityRepository;
    private readonly IGuidProvider guidProvider;
    private readonly IDomainEventPublisher eventPublisher;

    public AddCountryCommandHandler(IRepository<City> cityRepository,
        IGuidProvider guidProvider, IDomainEventPublisher eventPublisher) { ... }

    public void Handle(AddCityCommand command)
    {
        City city = cityRepository.Create();

        city.Id = this.guidProvider.NewGuid();
        city.CountryId = command.CountryId;

        // Publish the event to notify the system
        this.eventPublisher.Publish(new CityAdded(city.Id));
    }
}

🔄 Reacting to Changes

Once the event is published, a specific event handler listens for CityAdded. This handler is responsible for bridging the gap between the domain change and the read model cache.

public class InvalidateGetCitiesByCountryQueryCache : IEventHandler<CityAdded>
{
    private readonly IQueryCache queryCache;
    private readonly IRepository<City> cityRepository;

    public InvalidateGetCitiesByCountryQueryCache(...) { ... }

    public void Handle(CityAdded e)
    {
        Guid countryId = this.cityRepository.GetById(e.CityId).CountryId;
        
        // Explicitly invalidate the specific query based on domain logic
        this.queryCache.Invalidate(new GetCitiesByCountryQuery(countryId));
    }
}

To simplify key generation, you can serialize the query object to JSON to create a unique deterministic key. This ensures that unique parameters automatically generate unique cache entries without manual string formatting.


⏱️ Timing and Consistency

Invalidation timing is critical. If you invalidate before the transaction commits, the cache might repopulate with old data. If you invalidate after, there is a brief window of inconsistency.

A robust pattern involves processing these events immediately after the transaction commits. We can achieve this using another decorator on the command handler:

public class EventProcessorCommandHandlerDecorator<T> : ICommandHandler<T>
{
    private readonly EventPublisherImpl eventPublisher;
    private readonly IEventProcessor eventProcessor;
    private readonly ICommandHandler<T> decoratee;

    public void Handle(T command)
    {
        this.decoratee.Handle(command); // Execute logic

        // Process queued events after the command completes
        foreach (IDomainEvent e in this.eventPublisher.GetQueuedEvents())
        {
            this.eventProcessor.Process(e);
        }
    }
}

🚀 Conclusion

While introducing domain events solely for caching might seem like over-engineering, it provides a clean and scalable architecture. It ensures that your write model remains focused on business logic while independent event handlers manage infrastructure concerns like cache consistency. This separation makes the system easier to maintain and extend as new requirements emerge.