Chapter 6 — Caching the catalog
Goal: stop hitting the database for the product list on every page load. You'll write: a cached read accessor under
Features/Products/.
The catalog changes rarely but is read constantly. Rask.Cache gives you a typed cache — again backed by
the same SQLite database, so there's nothing new to run — and the line you'll write most reads as what it
does: await Cache.Remember("products", Load).For(5.Minutes).
1. Write the cache accessor
Create Features/Products/CatalogCache.cs: a small class that owns one cached value — its key, its
expiry, how to compute it, and how to forget it.
That grouping is the point. The tempting shape is an inline Cache.Remember("catalog:all", …) at each
call site, and it works right up until the day the data changes and one of the four places that should
have dropped the key didn't. A cache you can't find every use of is a cache you can't reason about.
public static class CatalogCache
{
// Versioned rather than mutated in place: bump the suffix when the shape of the value changes and
// stale entries under the old key are simply never read again.
private const string Key = "catalogcache:v1";
public static Task<IReadOnlyList<ProductListItem>> Get() => …
public static Task Forget() => …
}
Static, and nothing injected: Cache reaches the cache of whatever work is running — a render, a request,
a handler, a job — so there is no constructor to thread through and no registration line to remember.
The cache battery is on and its table is mapped, and the first migration created it. That is the whole of
the setup.
2. Cache the product list
Fill in Get() with the read you actually want to avoid. Two notes on the shape:
- Project to a record. Cache values round-trip as JSON, and the
Productentity's private setters don't survive that. A lightweightProductListItemdoes — andSelectreads only the columns it names. - Say how long it keeps.
Rememberreturns the stored value or runs your loader and stores what it returns;.For(5.Minutes)is how long that answer stands. Nothing is read or loaded until you await it.
public sealed record ProductListItem(Guid Id, string Name, decimal Price, bool InStock, int Version);
public static Task<IReadOnlyList<ProductListItem>> Get() =>
Cache.Remember(Key, Load).For(5.Minutes);
private static async Task<IReadOnlyList<ProductListItem>> Load() =>
await Product
.OrderBy(p => p.Name)
.Select(p => new ProductListItem(p.Id, p.Name, p.Price, p.InStock, p.Version));
Version rides along because the list's delete button sends it back (Chapter 2). Current.Cancellation is
the same ambient idea as Cache itself: the cancellation of the work in progress, with nothing passed in.
Then have ProductsPage read through the accessor instead of asking the database for each page. The page
loads the list when it mounts, and reloads it after a delete:
[Route("/products")]
public sealed partial class ProductsPage : Component
{
private IReadOnlyList<ProductListItem> _items = [];
protected override async Task OnMount() => await Load();
private async Task Load() => _items = await CatalogCache.Get();
// … and in Render(), the table's rows are one page of the list, and a delete reloads it:
// _items.Skip((_listing.Page - 1) * PageSize).Take(PageSize).Select(p => Ui.TableRow.Key(p.Id)[ … ])
// DeleteProduct.Id(p.Id).Version(p.Version).OnDeleted(Load)
}
The cells read p.Name, p.Price and p.InStock either way, so the table itself does not change. What
does change is where the work happens: with a list rather than a query, the page sorts and pages it in
memory. That's the right trade for a set small enough to cache — and being small enough to cache is the
reason you're here.
3. Forget it when the catalog changes
A cache is only correct if you clear it when the underlying data changes. Finish the accessor with the other half of the pair:
public static Task Forget() => Cache.Forget(Key);
Then in the three components that write (CreateProduct.cs, UpdateProduct.cs, DeleteProduct.cs), right
after the write succeeds — before navigating away:
await CatalogCache.Forget();
Forgetting at the point of the write — not on a timer, not on read — is what keeps the cache from serving an answer you know is wrong. The expiry is a backstop for the cases you forget, not the mechanism.
Now the list is served from cache until it expires or someone edits the catalog, whichever comes first.
Trimming / AOT. Cached values round-trip as JSON, which by default means reflection. If you publish trimmed or AOT, name a source-generated context once in
Program.cs—app.Configure(c => c.Cache.Configure(o => o.Json = AppJson.Default))— and everyRemember,SetandGetstays exactly as written.
Verify
- Load
/productstwice — the second load doesn't run theSelect…ToListAsyncquery (add a log line inLoadto see it fire only on a miss). - Create or delete a product and reload — the list reflects the change immediately (the
Forgetdropped the key), then is served from cache again.