← Open in the full interactive course (progress tracking, search & more)

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.

What Is 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.

DataCached?Why
Product catalog (GET /api/products) Yes — IDistributedCacheRead constantly, written rarely, by staff only, through one known path
A single order's current status NoChanges 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 NoSmall, cheap query already (336), scoped per customer — little to gain, real staleness risk to manage

Why Does It Exist?

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.

Big Picture — Where Redis Sits

EVERY ORDERFLOW INSTANCE, ONE SHARED CACHE
Instance A     Instance B     Instance C
↓                  ↓                ↓
Shared Redis cache — one product catalog, one answer, for all three
↓ (cache miss, or after invalidation)
OrderFlowDbContextProducts table (336)

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.

How It Works — Cache-Aside for ProductService

266'S CACHE-ASIDE SHAPE, APPLIED TO ORDERFLOW'S CATALOG
1. A READ CHECKS THE CACHE FIRST
2. ON A MISS, FALL THROUGH TO THE DATABASE, THEN POPULATE THE CACHE
3. A WRITE (STAFF UPDATING A PRODUCT) INVALIDATES EXPLICITLY, ON THE SAME PATH
4. A SHORT EXPIRATION IS THE SAFETY NET, NOT THE PRIMARY MECHANISM

Simple Example — ProductService, Cache-Aside

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.

Real-World Example — a Price Change, Traced End to End

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.

Analogy

One Shared Price Board, Not Three Separate Notepads

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.

Under the Hood — What Happens the Moment a Bulk Price Update Skips ProductService

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.

Common Confusion

1. "Caching more of OrderFlow's data would make it faster overall" — not for data this write-heavy

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.

2. "Redis being down means OrderFlow is down" — cache-aside degrades, it doesn't collapse

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.

Common Mistakes

Mistake 1 — Reaching for IMemoryCache because it's simpler to set up

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.

Mistake 2 — Relying on the 5-minute TTL as the only invalidation mechanism

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.

Mistake 3 — Caching an order's live status "since it's read often too"

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.

When Should I Use It?

Rule of thumb: If you can't name every write path that touches the data you're about to cache, you don't yet know everywhere invalidation needs to happen — find those paths first, or accept a longer, deliberately-tolerated staleness window instead.

Mental Model

Cached = the product catalog — read constantly, written rarely, through one known path.
Not cached = order status, order history — write-heavy or too small to bother.
IDistributedCache + Redis = one shared answer across every OrderFlow instance, per 266 — not 265's per-process IMemoryCache.
Invalidation = explicit, on every write path, with a short TTL as the safety net — never the TTL alone.

Remember: a Redis outage degrades the catalog to a plain database read — it doesn't take OrderFlow down.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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.