265 taught IMemoryCache, 266 taught why it breaks across instances, 280 taught invalidation discipline. OrderFlow runs as more than one instance — so only one of those three was ever going to be the right call here.
333 fixed OrderFlow's first constraint as scale — more than one instance behind a load balancer, nothing allowed to assume there's only one copy of the running process. 336 just built the read path that gets hit hardest under that constraint: the product catalog, queried on nearly every page a customer looks at, changing far less often than it's read. 265's IMemoryCache would be the wrong tool the instant OrderFlow scales past one instance — each instance would build its own separate, invisible-to-each-other copy of "is this product in stock," which is exactly the failure mode 266 exists to solve.
This lesson doesn't re-teach IMemoryCache, IDistributedCache, or the cache-aside pattern — 265, 266, and 280 already did that in depth. What follows is the actual decision for OrderFlow: what gets cached, what doesn't, and the exact invalidation path that keeps a customer from ever seeing a product's old price after staff updates it.
OrderFlow caches exactly one thing with any real intent: the product catalog, behind IDistributedCache (266), backed by Redis, shared across every running instance. Everything else in OrderFlow's data — a specific customer's order history, an order's current status mid-pipeline — is either too write-heavy or too sensitive to staleness to be worth caching at all, per 280's own trade-off framing.
| Data | Cached? | Why |
|---|---|---|
| Product catalog (GET /api/products) | Yes — IDistributedCache | Read constantly, written rarely, by staff only, through one known path |
| A single order's current status | No | Changes across every step of 338's pipeline — the write-to-read ratio is far too close to 1:1 for caching to pay off, per 280 |
| A customer's own order history | No | Small, cheap query already (336), scoped per customer — little to gain, real staleness risk to manage |
Without caching, every product listing request hits OrderFlowDbContext and re-runs the same catalog query, over and over, for data that's identical across thousands of requests in a row. 336 already built that query correctly — projected, untracked — but "correctly" still means a real round-trip to the database on every single read. 266's whole argument is exactly OrderFlow's situation: with more than one instance behind a load balancer, an in-process cache (265) can't be trusted to be consistent, because each instance would independently decide when its own copy is stale. A shared, external cache is the only shape that keeps every instance looking at the same answer.
Every instance reads and invalidates the same keys — there's no scenario where Instance A sees an updated price and Instance B doesn't, the exact guarantee 265's per-process IMemoryCache could never make once OrderFlow runs as more than one process.
public class ProductService(IProductRepository repository, IDistributedCache cache)
{
private static string CacheKey(Guid id) => $"product:{id}";
public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
{
var cached = await cache.GetStringAsync(CacheKey(id), ct);
if (cached is not null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await repository.GetByIdAsync(id, ct);
if (product is not null)
{
await cache.SetStringAsync(
CacheKey(id),
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) },
ct);
}
return product;
}
public async Task UpdateAsync(Product product, CancellationToken ct)
{
await repository.UpdateAsync(product, ct); // 336's write path
await cache.RemoveAsync(CacheKey(product.Id), ct); // event-based invalidation — 280
}
}Meaning: The read path checks Redis before the database; the write path invalidates Redis the moment the database change is saved. Every instance running this same code reads and writes the exact same cache keys — there's no per-instance divergence to worry about, unlike 265's IMemoryCache would have produced.
Staff corrects a mispriced product at 2:14pm. ProductService.UpdateAsync saves the new price to the Products table and, in the same call, removes product:{id} from Redis. The very next customer request to GET /api/products/{id} — regardless of which OrderFlow instance happens to receive it — misses the cache, falls through to the database, and reads the corrected price. Compare that to what 265's IMemoryCache would have done in the same scenario: only the specific instance that happened to handle the staff member's update request would know to invalidate its own local copy; every other instance would keep serving the old price out of its own separate cache, with no way to be told otherwise, until its own local expiration eventually caught up.
Three cashiers at three separate registers, each keeping their own personal notepad of "today's prices," is 265's IMemoryCache under OrderFlow's scale constraint — the moment a price changes, whoever updates their own notepad first leaves the other two quietly wrong until they happen to notice. One shared price board on the wall, that every register reads from and any of them can correct, is 266's IDistributedCache — the correction is visible to every register the instant it's made, because there was never more than one copy to begin with.
280 already flagged this exact risk: any write path that changes the Products table without going through the code that calls cache.RemoveAsync leaves a stale cache entry behind with nothing to catch it except the 5-minute TTL. For OrderFlow specifically, that means a bulk import script, a direct database migration, or an admin tool that writes to Products using raw SQL or ExecuteUpdateAsync instead of calling ProductService.UpdateAsync is a genuine invalidation gap — the TTL safety net exists precisely because this kind of gap is realistic, not hypothetical, in a system with more than one way to touch the same table over its lifetime.
An order's status changes multiple times as it moves through 338's pipeline — placed, payment confirmed, stock reserved, shipped. Caching it would mean invalidating on nearly every write, which is exactly the 1:1 write-to-read ratio 280 warned pays for all of caching's complexity and returns almost none of its benefit. The product catalog earns caching specifically because it's read far more than it's written — that ratio, not "is this data important," is the deciding factor.
Because ProductService falls through to IProductRepository on any cache miss, a Redis outage doesn't take the catalog offline — every read just becomes a direct database query again, exactly as if caching had never been added. Slower, not broken. This is a direct consequence of cache-aside's shape (266), not something OrderFlow had to add extra code for.
Choosing IMemoryCache for the product catalog because it needs no external service and the code is marginally shorter — ignoring that OrderFlow's own scale constraint (333) rules it out the moment a second instance starts. The scale constraint was fixed before this lesson started — IDistributedCache isn't a preference here, it's a requirement that follows directly from an earlier decision.
Skipping the explicit cache.RemoveAsync call in UpdateAsync, reasoning "it'll expire in five minutes anyway." Five minutes of a customer seeing a wrong, already-corrected price is a real, avoidable problem for an e-commerce business — event-based invalidation is the primary mechanism precisely so that gap doesn't have to exist for a known, controllable write path.
Applying the same cache-aside pattern to GetOrderStatusAsync because it's called frequently by a customer checking on their order. Read frequency alone doesn't justify caching — an order's status changes just as often as (or more often than) it's individually checked, putting it firmly on the wrong side of 280's ratio; it stays a direct, uncached database read.
You've seen exactly what OrderFlow caches, what it doesn't, and how invalidation is kept honest. Let's confirm the reasoning holds.
1. Why does OrderFlow use IDistributedCache for the product catalog instead of IMemoryCache?
Correct: B
Why B is correct: This is the exact reasoning from Why Does It Exist? and the Real-World Example — OrderFlow's scale constraint means multiple instances exist, and only a shared, external cache keeps them all looking at the same catalog data.
Why A is incorrect: IMemoryCache can store any .NET object, Product included — this isn't a real technical limitation.
Why C is incorrect: IMemoryCache is actually faster per-lookup (no network round-trip) — IDistributedCache is chosen for consistency across instances, not raw speed.
Why D is incorrect: IMemoryCache is a free, built-in .NET feature with no cloud dependency at all.
Reinforcement: The deciding factor is deployment shape (multiple instances needing a shared view), not raw performance or cost.
2. Why does this lesson decide NOT to cache an order's live status, even though it's read frequently by customers checking on their orders?
Correct: B
Why B is correct: Common Confusion #1 states this directly — read frequency alone doesn't justify caching; it's the ratio of reads to writes that matters, and order status changes about as often as it would be read, which is precisely the case 280 warned against caching.
Why A is incorrect: Size isn't the stated reason — the lesson's reasoning is entirely about the write-to-read ratio, not payload size.
Why C is incorrect: Order status is a real column on the Orders table, established back in 336 — this option contradicts the schema already built.
Why D is incorrect: The product catalog, which customers read, is exactly what OrderFlow does cache — caching eligibility isn't about staff vs. customer data.
Reinforcement: Cache eligibility is a function of the write-to-read ratio, not of how often something is read in isolation.
3. A bulk price-import script updates the Products table directly with raw SQL, bypassing ProductService.UpdateAsync entirely. What does this lesson say happens to the cached prices for those products?
Correct: B
Why B is correct: Under the Hood states this directly — any write path that bypasses ProductService.UpdateAsync skips the explicit cache.RemoveAsync call, leaving the TTL as the only remaining safety net until it naturally expires.
Why A is incorrect: Redis has no built-in awareness of the application's own database — nothing automatically connects a raw SQL update to a cache invalidation without explicit application code doing it.
Why C is incorrect: A stale cache entry causes wrong data to be served, not an exception — the request still succeeds, just with an outdated value.
Why D is incorrect: Nothing in OrderFlow's design enforces this at startup — it's a discipline the team has to maintain, not a technical guarantee the system provides automatically.
Reinforcement: The TTL exists precisely because invalidation gaps like this are realistic — it's the safety net, not proof the gap can't happen.
The catalog stays fast and correct across every instance. Next: 338 moves payment, inventory, and shipping off the checkout request entirely — applying lesson 165's hosted services to OrderFlow's real async pipeline for the first time.
dotnetmadeeasy.com — Learn C# and .NET, the right way.