14 Years of Enterprise ASP.NET, Part 3: Performance, Microservices & Design Patterns That Earn Their Keep
Part 3 of a 14-year ASP.NET series: the performance techniques that move p95, when microservices help, and the patterns that earn their keep.
- Author
- Randhir Jassal
- Published
- Reading time
- 16 min read
- Views
- 44 views
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. Three hard-won lessons: the ASP.NET Core performance techniques that actually move p95, why microservices are a tool and not a trophy, and the handful of design patterns that pay rent every week. Real before/after, diagrams, and Mattrx numbers.
Recap
Part 3 of a four-part series distilling 14+ years of enterprise ASP.NET. Part 1 was code craft; Part 2 was the data layer. Now: the app tier and the shape of the system.
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 — ASP.NET Core Performance Techniques
After a dozen performance investigations, the techniques that consistently move the needle are boring and few. Latency and throughput are gated by your scarcest shared resource — threads, GC headroom, connections — not by how clever your code is. The wins come in a predictable order.
Don't optimize code; remove ceilings. Async hygiene frees threads, caching removes work entirely, and the cheapest request is the one your app never runs. Micro-optimizing a method while a
.Resultstarves the thread pool is rearranging furniture in a burning room.
Async all the way (the thread-starvation ceiling)
// BEFORE — one blocking call starves the whole thread pool under load
public TenantContext Current => _store.GetByHostAsync(_host).Result; // blocks a pool thread
// 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 (output cache + HybridCache)
// AFTER — hot GETs served from cache; reference data via HybridCache (L1+L2, stampede-safe)
builder.Services.AddOutputCache(o => o.AddPolicy("kpis", b => b.Expire(TimeSpan.FromSeconds(60))));
app.MapGet("/api/dashboard/kpis", GetKpis).CacheOutput("kpis");
public ValueTask<TenantConfig> GetConfig(Guid id, CancellationToken ct) =>
cache.GetOrCreateAsync($"cfg:{id}", t => LoadFromDb(id, t), cancellationToken: ct);
Let the runtime help (Server GC + pooling)
<!-- AFTER — Server GC: per-core heaps, short pauses, higher throughput -->
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
# diagnostic: the five-command opener for any throughput hunt
dotnet-counters monitor -p <pid> System.Runtime # ThreadPool Queue Length, % Time in GC, alloc rate
Mattrx metric: in priority order — fixing sync-over-async, output/HybridCache, and Server GC — took API p95 from 480 ms to 120 ms, lifted sustained throughput per instance ~3×, and let the web tier shrink from P2v3×6 to P1v3×2 + autoscale. (The full teardown is its own post — linked below.)
Lesson 8 — Microservices: Use Them Wisely
I've built microservices that saved teams and microservices that destroyed velocity. The difference was never the technology — it was whether the boundaries were real. The most common failure mode in enterprise .NET isn't "we should have used microservices"; it's a distributed monolith: services that can't deploy independently because they share a database and call each other synchronously for every request. You got all the cost of distribution and 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. Distribution is a cost you pay for independence; if you don't get independence, you just bought latency, partial failure, and distributed transactions for nothing.
Before — the distributed monolith
BEFORE — "microservices" that are really one app cut into networked pieces
┌──────────┐ sync HTTP ┌──────────┐ sync HTTP ┌──────────┐
│ Campaigns │ ──────────► │ Billing │ ──────────► │ Reports │
└────┬─────┘ └────┬─────┘ └────┬─────┘
└──────────── shared SQL database ─────────────────┘
- can't deploy one without the others (shared schema)
- one request fans out to 3 services (latency adds up, any down = all down)
- a "transaction" now spans services → distributed-transaction pain
After — modular monolith, extract on real seams
AFTER — modules in ONE deployable; extract only what truly needs independence
┌───────────────────────────────────────────────┐
│ ASP.NET Core (one deploy) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Campaigns │ │ Billing │ │ Reports │ modules │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ in-process calls,
│ └─ own schemas, clear interfaces ─┘ │ one transaction where it fits
└───────────────────────────────────────────────┘
│ extract ONLY this — different scaling profile
▼
┌──────────────────┐ async (queue) the Reports PDF worker:
│ Reports worker │ ◄───────────── bursty (1.2M/48h month-end),
│ (separate deploy) │ CPU-heavy, scales 0→40 independently
└──────────────────┘
The lesson in one move: 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.
Mattrx metric: resisting a full microservices split kept the team of 5 backend engineers shipping daily instead of fighting distributed-transaction bugs; extracting just the Reports worker let it autoscale independently and saved ~$1,300/month in compute by right-sizing that tier separately from the web tier. Cross-service "it's down because something else is down" incidents stayed at 0 because there's nothing to fan out to on the hot path.
Lesson 9 — Design Patterns Every Senior Developer Should Know
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 almost weekly: Strategy (swap behavior), Decorator (wrap behavior — caching, logging, retry), Mediator (decouple request from handler — this is CQRS), and Factory/Options (construct the right thing). The anti-pattern I see most: the generic Repository<T> over an ORM that's already a repository.
Strategy (kill the growing switch)
// Covered in Part 1 as SOLID/OCP — same shape: IReportFormatter picked by key, no switch to edit.
Decorator (cross-cutting behavior without touching the class)
// AFTER — 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);
// other members delegate straight to `inner`
}
// DI: register the real repo, then decorate it — callers never know.
builder.Services.AddScoped<CampaignRepository>();
builder.Services.AddScoped<ICampaignRepository>(sp =>
new CachedCampaignRepository(sp.GetRequiredService<CampaignRepository>(),
sp.GetRequiredService<HybridCache>()));
Mediator (this is what CQRS is built on)
// AFTER — the controller knows nothing about handlers; behavior is decoupled and pipeline-able
public sealed class PublishCampaignHandler(...) : IRequestHandler<PublishCampaign, Result>
{
public Task<Result> Handle(PublishCampaign cmd, CancellationToken ct) { /* ... */ }
}
// validation, logging, transactions become MediatR pipeline behaviors — cross-cutting, once.
The anti-pattern: generic Repository over EF
// AVOID — IRepository<T> over EF Core hides LINQ, blocks projections/AsNoTracking, adds nothing.
// EF's DbSet<T> IS already a repository + unit of work. Wrap it only for a real domain reason.
Mattrx metric: the Decorator pattern added caching to the three hottest repositories with zero changes to the original classes or their tests; introducing MediatR pipeline behaviors centralized validation + transaction handling and removed ~400 lines of repeated try/transaction/validate boilerplate across handlers (the same theme as the middleware post — cross-cutting concerns belong in one place).
The thread through all three
Performance → remove ceilings (threads, GC, work) before micro-optimizing
Microservices → distribution buys independence; don't pay for it without the benefit
Patterns → vocabulary you reach for when code smells — not a checklist to apply
Together: a fast app tier, a system split only where it must be, and patterns
that appear because the code asked for them.
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. Most architectural damage I've cleaned up came from doing these eagerly instead of when warranted.
Continue the series
This is Part 3 of 4 in 14 Years of Enterprise ASP.NET.
→ Next: Part 4 — Azure, Observability & AI in Real Systems — the final part: running it in the cloud, actually being able to see what it's doing, and where AI fits into enterprise architecture in 2026.
The full series:
- Part 1: Clean Code, OOP & SOLID
- Part 2: LINQ, EF Core & SQL Server
- Part 3 (you are here): Performance, Microservices & Design Patterns
- Part 4: Azure, Observability & AI
Further reading
- Scaling ASP.NET Core APIs to 100,000 Requests Per Minute — Lesson 7 in full, the entire p95 480→120 teardown.
- When NOT to Use Microservices: A Decision Framework — Lesson 8 as a checklist.
- Five Design Patterns I Use Daily in C# — Lesson 9 expanded with more examples.
- CQRS in C# — A Complete Guide with Real Code — the Mediator pattern as an architecture.
Tempted to split into microservices, or unsure which pattern a smell is asking for? Email randhir.jassal@gmail.com with the architecture decision you're weighing and I'll give you the trade-off straight.
Get the next issue
A short, curated email with the newest posts and questions.