Skip to content

Caching a query

Cache a handler's responses with [CacheFor], and invalidate them from the write handler.

3 min read

Immediate.Cache wraps a whole handler — pipeline included — in an IMemoryCache-backed cache class. A cache hit skips the handler and every behavior attached to it.

Create the cache

Features/GetTodoQueryCache.cs
using Immediate.Cache.Shared;

namespace Todo.Features;

[CacheFor<GetTodoQuery>]
public sealed partial class GetTodoQueryCache
{
	protected override string TransformKey(GetTodoQuery.Query request) =>
		$"GetTodo({request.Id})";
}

That’s the whole cache. The generator emits the base class ApplicationCache<GetTodoQuery.Query, TodoItem?> and a constructor taking IMemoryCache and an owned handler.

Note also that the cache class itself must be partial (the generator adds the base class and constructor to it) and must not be nested (IC0001).

Read through the cache

Consumers call the cache, not the handler. That means the HTTP endpoint moves off GetTodoQuery and onto a thin handler that reads through the cache:

Features/GetTodoQuery.cs
[Handler]
// [MapGet("/api/todos/{id:int}")]  ← remove this; the endpoint moves below
public sealed partial class GetTodoQuery(TodoRepository repository)
{
	public sealed record Query
	{
		public required int Id { get; init; }
	}

	private ValueTask<TodoItem?> HandleAsync(
		Query query,
		CancellationToken token
	) => ValueTask.FromResult(repository.GetById(query.Id));
}
Features/GetTodo.cs
using Immediate.Apis.Shared;
using Immediate.Handlers.Shared;
using Microsoft.AspNetCore.Http.HttpResults;

namespace Todo.Features;

[Handler]
[MapGet("/api/todos/{id:int}")]
public sealed partial class GetTodo(
	TodoRepository repository,
	GetTodoQueryCache cache
)
{
	public sealed record Query
	{
		public required int Id { get; init; }
	}

	internal static Results<Ok<TodoItem>, NotFound> TransformResult(TodoItem? result) =>
		result is null
			? TypedResults.NotFound()
			: TypedResults.Ok(result);

	private async ValueTask<TodoItem?> HandleAsync(
		Query query,
		CancellationToken token
	)
	{
		var operationLock = repository.GetOperationLock(query.Id);
		await operationLock.WaitAsync(token);
		try
		{
			return await cache.GetValue(new GetTodoQuery.Query { Id = query.Id }, token);
		}
		finally
		{
			_ = operationLock.Release();
		}
	}
}

Invalidate on write

Completing a todo makes the cached entry stale. Inject the cache into the write handler and drop the entry:

Features/CompleteTodoCommand.cs
[Handler]
[MapPut("/api/todos/{id:int}/complete")]
public sealed partial class CompleteTodoCommand(
	TodoRepository repository,
	GetTodoQueryCache cache
)
{
	public sealed record Command
	{
		public required int Id { get; init; }
	}

	internal static Results<Ok<TodoItem>, NotFound> TransformResult(TodoItem? result) =>
		result is null ? TypedResults.NotFound() : TypedResults.Ok(result);

	private async ValueTask<TodoItem?> HandleAsync(
		[AsParameters] Command command,
		CancellationToken token
	)
	{
		var operationLock = repository.GetOperationLock(command.Id);
		await operationLock.WaitAsync(token);
		try
		{
			var updated = repository.Complete(command.Id);
			cache.RemoveValue(new GetTodoQuery.Query { Id = command.Id });
			return updated;
		}
		finally
		{
			_ = operationLock.Release();
		}
	}
}

RemoveValue and SetValue are synchronous and return void. Only GetValue is awaitable. The read and completion handlers hold the same per-Todo operation lock until their cache work is finished. An in-flight read therefore cannot repopulate an entry with the pre-completion value after Complete removes it.

If you already have the new value in hand, SetValue writes it without ever running the handler:

cache.SetValue(new GetTodoQuery.Query { Id = command.Id }, updated);

Register it

Immediate.Cache needs IMemoryCache, so two calls rather than one:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<TodoRepository>();
builder.Services.AddTodoBehaviors();
builder.Services.AddTodoHandlers();
builder.Services.AddMemoryCache();
builder.Services.AddTodoCaches();

Forgetting AddMemoryCache() is a runtime DI failure, not a build error — it’s the most common first-run mistake with this package.

Run it

terminal
dotnet run
terminal
curl http://localhost:5000/api/todos/1   # runs the handler
curl http://localhost:5000/api/todos/1   # served from cache
curl -X PUT http://localhost:5000/api/todos/1/complete
curl http://localhost:5000/api/todos/1   # runs the handler again

Entries expire on a 5-minute sliding expiration by default. Override GetCacheEntryOptions() to change that, or to set size, priority or eviction callbacks — see Configuring cache entries.

What you have

A read path that runs the handler once per key and coalesces concurrent callers onto that single execution, with explicit invalidation from the write path.

Up next

Wiring up dependency injectionTodoRepository stops being registered by hand.