Production .NET services rarely fail because a developer forgot how to write an if statement. They fail at boundaries: a slow downstream API, a canceled request that keeps consuming resources, an innocent-looking LINQ query that loads half a database, or a diagnostic log that cannot explain why latency suddenly doubled.
Advanced .NET work is largely about making those boundaries explicit. The goal is not to apply every performance trick or architectural pattern. It is to identify the parts of a system where work crosses a process, thread, network, storage engine, or lifetime boundary—and make the behavior predictable.
This article collects patterns that pay off in real APIs, background workers, and data-heavy services.
1. Treat cancellation as part of the contract
CancellationToken is not an optional decoration for modern .NET code. It is a signal that the caller no longer values the result enough to spend more resources obtaining it.
Pass it through every asynchronous boundary:
The important detail is propagation. Accepting a token at the controller layer but omitting it in HttpClient, EF Core, message publishing, or file I/O creates a false sense of cancellation support.
For ASP.NET Core endpoints, HttpContext.RequestAborted is automatically bound when a CancellationToken parameter is declared. For background services, use the token passed to ExecuteAsync and link it with operation-specific limits when appropriate:
Do not convert every OperationCanceledException into a generic error. A canceled client request, a service shutdown, and a genuine timeout have different operational meanings. Log and classify them accordingly.
2. Prefer bounded concurrency over uncontrolled parallelism
Task.WhenAll is excellent when the input is small and every operation is independently safe. It becomes dangerous when used over thousands of records, because it can create thousands of pending requests, database commands, or allocations simultaneously.
Instead, establish a concurrency budget:
The correct number is not automatically Environment.ProcessorCount. CPU-bound transformations may benefit from a value near the processor count. I/O-bound work can safely exceed it, but only if the downstream dependency has capacity. A database connection pool, external API rate limit, or RabbitMQ consumer configuration often defines the real limit.
For pipelines with distinct stages, System.Threading.Channels offers more control. A bounded channel introduces backpressure: producers slow down instead of letting memory become an accidental queue.
Backpressure is a reliability feature. It turns overload into waiting, rejection, or controlled retry rather than an eventual out-of-memory event.
3. Make allocation-sensitive paths deliberately boring
Most business code does not need aggressive micro-optimization. Hot paths do. Serialization, logging, parsing, middleware, and high-volume message consumers can execute millions of times per day, where small allocations become meaningful garbage collection pressure.
Start with measurement. Use dotnet-counters, dotnet-trace, BenchmarkDotNet, and production telemetry before changing code. Then look for common sources of accidental allocation:
- Repeated string interpolation in disabled log statements.
- LINQ chains in tight loops.
- Creating new
HttpClientinstances. - Converting spans to strings unnecessarily.
- Captured closures inside frequently executed delegates.
Structured logging avoids much of the logging cost when a level is disabled:
For extremely hot logging paths, source-generated logging with LoggerMessage can reduce parsing and boxing overhead further.
When parsing protocol-like text, use ReadOnlySpan<char> or ReadOnlySpan<byte> to inspect slices without creating intermediate strings. But maintain a clear threshold: span-heavy code is valuable in a proven hot path, not as a default style for ordinary application logic.
4. Use IHttpClientFactory, but design the client boundary too
IHttpClientFactory solves important problems: handler lifetime management, DNS refresh behavior, central configuration, and integration with resilience policies. It does not automatically make an external integration correct.
A robust typed client should define timeouts, headers, error semantics, and idempotency at the boundary:
Avoid setting a broad HttpClient.Timeout and assuming every request has the same latency budget. A user-facing pricing lookup and a nightly synchronization job should often have different timeout and retry strategies.
Retries must also be selective. Retrying a GET after a transient failure is usually reasonable. Retrying a POST can duplicate an order unless the remote API supports idempotency keys or your operation is inherently idempotent. Resilience is not “retry everything”; it is preserving correctness when failure is normal.
5. Shape EF Core queries before they reach the database
The most important EF Core optimization is usually not an index hint or a compiled query. It is asking for the right data.
Avoid loading entities when a read model only needs a few fields:
Projection reduces transferred columns, materialization work, and change tracking overhead. AsNoTracking() is an especially strong default for read-only queries.
Be suspicious of implicit relationship loading and unbounded Include graphs. A query that appears to return 20 orders can produce hundreds or thousands of joined rows when collections are included. In those cases, use projections, pagination, filtered includes, or AsSplitQuery() after inspecting the generated SQL and actual execution plan.
For write paths, remember that SaveChangesAsync is a unit-of-work boundary, not a convenient method to call after every individual property update. Batch related changes when transaction semantics allow it, while keeping transaction duration short.
6. Keep async code asynchronous all the way down
Deadlocks are less common in ASP.NET Core than in older synchronization-context-heavy applications, but blocking is still expensive. Calling .Result, .Wait(), or synchronous I/O inside request processing ties up threads that could serve other requests.
Use await through the full call chain. If a library only offers synchronous work, isolate it and consider whether it belongs on the request path at all. CPU-heavy work may need a queue and a background worker rather than being performed inline.
Also avoid wrapping naturally asynchronous I/O in Task.Run. This does not make network or database calls more asynchronous; it merely consumes a thread-pool thread to wait for them. Reserve Task.Run for deliberately offloading CPU-bound work when that trade-off is justified.
7. Build observability into boundaries, not after incidents
Logs answer discrete questions. Metrics show trends. Traces show a request’s path across components. Production-grade services need all three.
Use ActivitySource around meaningful operations, especially custom workflows and external calls:
Do not place customer emails, access tokens, addresses, or large payloads in activity tags. Observability data has a wide audience and a long retention period.
For metrics, favor low-cardinality dimensions. A status code, endpoint name, or operation type is generally safe. A user ID or order ID can create an unbounded number of time series and make a metrics platform slower and more expensive precisely when you need it most.
8. Use time as a dependency
Tests and business rules become fragile when they call DateTime.UtcNow throughout the codebase. Use TimeProvider to make time controllable:
Register TimeProvider.System in production and use a fake provider in tests. This makes expiry logic, retry windows, scheduled behavior, and time-zone-sensitive defects easier to verify without waiting for the clock.
Final thought
Advanced .NET development is less about obscure language features than about disciplined resource management. Cancel work that no longer matters. Bound concurrency. Query only the data you need. Respect remote dependencies. Measure allocations before optimizing. Emit enough telemetry to explain behavior without leaking sensitive data.
These practices create services that remain understandable under load, diagnosable during incidents, and adaptable as requirements grow. That is the real advantage of mature .NET engineering: not cleverness, but control at the boundaries where systems become unpredictable.
No comments yet. Be the first to share your thoughts!