Exception handling in C# uses try, catch, and finally to handle failures and guarantee cleanup. In a production ASP.NET Core API handling 5,000 requests/second, the difference between a well-structured exception strategy and ad-hoc catch (Exception) blocks is the difference between actionable Application Insights traces with full stack context and a wall of swallowed errors that hide the root cause for days. In modern C#, using / await using is the preferred way to ensure Dispose / DisposeAsync runs.
trycontains code that may throw.catchhandles exceptions you know how to handle.finallynormally runs when leaving thetryblock (success or failure) and is used for cleanup.throw;rethrows the current exception and preserves the original stack trace.- If no matching
catchexists in the current method, the runtime searches up caller frames for a compatible handler (includingwhenfilters). After a handler is selected, the stack is unwound andfinallyblocks run on the path to that handler.
Example:
try
{
await ProcessAsync(ct);
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.Message);
throw;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log(ex);
throw;
}
finally
{
Cleanup();
}throw keyword
throw is how you signal that code cannot continue normally and must transfer control to an exception handler.
Why we need it
- It fails fast when invariants are broken (for example, invalid arguments or invalid state).
- It creates a stack trace from the throw site, which is critical for debugging.
- It lets higher layers decide how to handle the failure (retry, map to HTTP response, stop processing).
When to use
- Argument validation in public APIs (
ArgumentNullException,ArgumentException,ArgumentOutOfRangeException). - Invalid object state where continuing would produce incorrect behavior (
InvalidOperationException). - Rethrow inside
catchwiththrow;after logging to preserve the original stack trace. - Wrap and throw a new exception (with
innerException) when you need domain-specific context. - Throw expressions in guard clauses for concise validation.
public static string NormalizeName(string? value)
{
var name = value ?? throw new ArgumentNullException(nameof(value));
if (name.Length > 100)
{
throw new ArgumentOutOfRangeException(nameof(value), "Name is too long.");
}
return name.Trim();
}Custom Exceptions
Create a custom exception type when callers need to catch your failure specifically (vs a generic InvalidOperationException). Derive from Exception (not ApplicationException, which is obsolete), provide the three conventional constructors, and prefer immutable, meaningful state:
public sealed class OrderNotFoundException(int orderId)
: Exception($"Order {orderId} was not found.")
{
public int OrderId { get; } = orderId;
}Don’t create a custom type when a built-in one already communicates intent (ArgumentException, KeyNotFoundException, TimeoutException). Note that [Serializable] + the serialization constructor are legacy (binary serialization is obsolete in modern .NET) — skip them unless you specifically cross an AppDomain/remoting boundary.
Aggregated and Cross-Boundary Exceptions
-
AggregateExceptionwraps multiple failures — it’s whatTask.WhenAlland parallel code produce.awaitrethrows only the first; to handle all, inspecttask.Exceptionand use.Flatten()(collapse nested aggregates) and.Handle(predicate)(mark some inner exceptions as handled, rethrow the rest). See Tasks. -
ExceptionDispatchInfolets you capture an exception and rethrow it later — from another thread or stack frame — without losing the original stack trace:ExceptionDispatchInfo? captured = null; try { Work(); } catch (Exception ex) { captured = ExceptionDispatchInfo.Capture(ex); } // ...later, elsewhere... captured?.Throw(); // original throw-site preservedThis is exactly the machinery
awaituses to surface task exceptions at the real failure location.
Global / Last-Chance Handlers
Always have a top-level net so a stray exception is logged, not silently lost or process-killing:
- ASP.NET Core:
app.UseExceptionHandler(...)(return RFC 7807 Problem Details). AppDomain.CurrentDomain.UnhandledException— last-chance log before the process terminates (you cannot stop it).TaskScheduler.UnobservedTaskException— catches faults from tasks nobody awaited (see the fire-and-forget pitfall).AppDomain.CurrentDomain.FirstChanceException— fires for every exception as it’s thrown (before any catch); useful for diagnostics, noisy in production.
Pitfalls
- Exceptions as control flow: throwing for expected branches (for example, routine
not found) is expensive and obscures business logic. On a hot path processing 10,000 order lookups/second, usingthrow new NotFoundException()for cache misses (a 30% miss rate) generated 3,000 exceptions/second — each costing ~15μs for stack trace capture, adding 45ms of CPU time per second and triggering garbage collection pressure from theExceptionobjects. Use explicit branching (Try*patterns, nullable results, or domain result types) for expected outcomes. throw ex;incatch: this restarts the stack trace at the rethrow point and loses the original call path. Usethrow;inside the samecatchto preserve debugging context.- Throwing in
finally: a new exception fromfinallycan mask the original failure fromtry/catch. Keepfinallyfocused on cleanup and avoid new throws there. - Overly generic exception types: throwing
Exceptionmakes handling and telemetry less actionable. Prefer specific built-in types (ArgumentException,InvalidOperationException, and related types) that communicate intent.
Tradeoffs
- Exceptions vs Result types: Exceptions are idiomatic for truly unexpected failures and integrate with .NET infrastructure (stack traces, logging middleware, global handlers). Result types (
Result<T, TError>,OneOf) make failure paths explicit at the call site with zero allocation overhead — meaningful in high-throughput hot paths. Use exceptions for infrastructure failures (I/O, network, invariant violation) and result types for expected domain failures (validation, business rule rejections, not-found). - Catch width: catching broad types (
catch (Exception)) is convenient but hides root causes and encourages accidental error swallowing. Narrow catches force awareness of specific failure modes. As a rule: only catch exceptions you know how to handle; let the rest propagate to a global handler. - Exception filters (
when): filters evaluate before stack unwinding, preserving the full original call stack in logging tools (Application Insights, Serilog). They enable observe-and-rethrow without cloning the exception type. Prefercatch (Exception ex) when (ShouldLog(ex))overcatch/log/throwpatterns when your goal is observation, not handling.
Questions
What is the difference between
throw;andthrow ex;inside acatchblock?
throw;preserves the original stack trace, whilethrow ex;resets it to the current method. In practice, this meansthrow;keeps the real failure path for debugging and observability.
When should you wrap an exception instead of rethrowing it directly?
Wrap when you need to add domain context or translate infrastructure exceptions at a boundary (for example, repository to application service). Keep the original error in
InnerExceptionso root cause details remain available.
Why is throwing from
finallyconsidered dangerous?A throw in
finallycan replace the original exception and hide root cause information. The safer pattern is cleanup-only logic infinally, with error handling done incatchor at higher boundaries.
When might
finallynot execute?When the process terminates abruptly and normal stack unwinding does not happen (for example, crash/kill,
Environment.FailFast(), orStackOverflowException).
References
- Exception-handling statements (try/catch/finally/throw) — language reference for all exception-handling syntax including
whenfilters and throw expressions. - Best practices for exceptions — Microsoft guidance on when to throw, when to catch, and how to design exception hierarchies.
- Exception throwing (Framework Design Guidelines) — API design rules for exception usage in library code.
- Using standard exception types — which built-in exception types to use for which scenarios.
- Catch handler search in call stack (Metanit) — Russian-language practitioner walkthrough of how the runtime searches for matching handlers up the call stack.