Originally published at prepstack.co.in
Part 3 of 4 — 14 Years of Enterprise ASP.NET. Once the code is clean and the data layer is fast, the next ceilings are in the app tier and the architecture itself.
Running example: Mattrx — .NET 9 / ASP.NET Core, 110k MAU, ~3,200 req/sec peak across six instances, Clean Architecture + CQRS via MediatR.
Lesson 7 — Performance: remove ceilings, don't optimize lines
Latency and throughput are gated by your scarcest shared resource — threads, GC headroom, connections — not by how clever your code is. Micro-optimizing a method while a .Result starves the thread pool is rearranging furniture in a burning room. The wins come in a predictable order.
Async all the way — one blocking call starves the whole pool under load:
// BEFORE — blocks a pool thread
public TenantContext Current => _store.GetByHostAsync(_host).Result;
// AFTER — async end to end; threads are never parked on I/O
public ValueTask<TenantContext> GetAsync(string host, CancellationToken ct)
=> _store.GetByHostAsync(host, ct);
Cache the work away — the cheapest request is the one you never run:
builder.Services.AddOutputCache(o => o.AddPolicy("kpis", b => b.Expire(TimeSpan.FromSeconds(60))));
app.MapGet("/api/dashboard/kpis", GetKpis).CacheOutput("kpis");
// reference data via HybridCache (L1+L2, stampede-safe)
Let the runtime help — Server GC (per-core heaps, higher throughput):
<ServerGarbageCollection>true</ServerGarbageCollection>
In priority order — fixing sync-over-async, output/HybridCache, then Server GC — took API p95 from 480 ms to 120 ms, lifted sustained throughput per instance ~3×, and let the web tier shrink from 6 big boxes to 2 + autoscale.
Lesson 8 — Microservices: a tool, not a trophy
The most common failure in enterprise .NET isn't "we should have used microservices" — it's the distributed monolith: services that can't deploy independently because they share a database and call each other synchronously for every request. All the cost of distribution, none of the benefit.
Start with a modular monolith. Extract a service only when a module has a different scaling, deployment, or team boundary — and can own its data. Mattrx kept Campaigns/Billing/Analytics as modules in one deployable and extracted only the Reports PDF worker — because it has a genuinely different profile (bursty to 1.2M reports/48h, CPU-bound, needs to scale 0→40 without touching the web tier) and owns its own work queue.
Resisting a full split kept 5 backend engineers shipping daily instead of fighting distributed-transaction bugs; extracting just the Reports worker saved ~$1,300/month by right-sizing that tier separately. Cross-service "it's down because something else is down" incidents stayed at 0 — there's nothing to fan out to on the hot path.
Lesson 9 — Patterns are vocabulary, not architecture
You don't set out to "use the Strategy pattern"; you notice a growing switch and reach for it. The handful I use weekly: Strategy (swap behavior), Decorator (wrap behavior — caching, logging, retry), Mediator (decouple request from handler — this is CQRS).
// Decorator — add caching to ANY repository by wrapping it; the original class is untouched
public sealed class CachedCampaignRepository(ICampaignRepository inner, HybridCache cache)
: ICampaignRepository
{
public ValueTask<Campaign?> GetAsync(Guid id, CancellationToken ct) =>
cache.GetOrCreateAsync($"campaign:{id}", t => inner.GetAsync(id, t), cancellationToken: ct);
}
The anti-pattern I see most: a generic Repository<T> over EF Core. EF's DbSet<T> is already a repository + unit of work — wrapping it hides LINQ, blocks projections/AsNoTracking, and adds nothing. The Decorator pattern added caching to the three hottest repositories with zero changes to the originals or their tests; MediatR pipeline behaviors centralized validation + transactions and removed ~400 lines of repeated boilerplate.
The thread through all three
Remove ceilings before micro-optimizing. Distribution buys independence — don't pay for it without the benefit. Patterns are vocabulary you reach for when code smells, not a checklist. The senior move in all three is restraint: optimize the ceiling not the line, split the service only on a real seam, apply the pattern only when the smell appears.
This is Part 3 of 4. Full post with every before/after, the diagrams, and the diagnostics — plus Parts 1, 2, and 4 — on PrepStack.
Top comments (0)