Exceptions carry failures across method boundaries when normal execution cannot continue. A try block marks code whose failure may be handled nearby. A matching catch decides whether recovery is possible, and finally protects cleanup that must happen while the stack unwinds. For disposable resources, using and await using usually express that cleanup more directly.
trycontains code that may throw.catchhandles exceptions for which local recovery or translation is defined.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.
Handler Boundaries
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 reports that the current operation cannot produce its promised result. The exception type is part of the API contract: it tells callers whether the problem is a bad argument, invalid state, timeout, cancellation, or something domain-specific.
What a Throw Preserves
- The stack trace identifies the path to the failure.
- A specific type lets a boundary select the failures it can handle.
- An inner exception keeps the lower-level cause when a boundary adds domain context.
Choosing an Exception
- Public argument validation normally uses
ArgumentNullException,ArgumentException, orArgumentOutOfRangeException. InvalidOperationExceptionfits a call that is valid in general but invalid for the object’s current state.- A domain exception is useful when callers need a stable, catchable failure contract that built-in types cannot express.
- A throw expression keeps a small guard clause close to the value it protects.
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
A custom type fits when callers need to distinguish the failure from other exceptions. Such a type derives from Exception, keeps useful state immutable, and preserves an inner exception when the custom type translates a lower-level failure.
public sealed class OrderNotFoundException(int orderId)
: Exception($"Order {orderId} was not found.")
{
public int OrderId { get; } = orderId;
}A built-in type is better when it already communicates the contract (ArgumentException, KeyNotFoundException, or TimeoutException). Legacy binary-serialization constructors do not belong in new exception types unless an old remoting contract still requires them.
Aggregated and Cross-Boundary Exceptions
-
AggregateExceptionrepresents multiple failures from parallel or task-based work. Awaiting a faultedTask.WhenAllpropagates one exception, while the completed task’sExceptionproperty retains the aggregate.Flatten()removes nested aggregates, andHandle(predicate)marks selected inner exceptions as handled. Tasks describes how task-based work aggregates these failures. -
ExceptionDispatchInfocaptures an exception so it can be thrown later without replacing its original throw-site information:ExceptionDispatchInfo? captured = null; try { Work(); } catch (Exception ex) { captured = ExceptionDispatchInfo.Capture(ex); } // ...later, elsewhere... captured?.Throw(); // original throw-site preservedThis is useful when infrastructure must move a failure across an asynchronous or queue boundary without pretending the later throw site caused it.
Global / Last-Chance Handlers
Unhandled exceptions need an application boundary that records the failure and converts it only when the host has a valid response model.
- ASP.NET Core can use
app.UseExceptionHandler(...)to log a failure and return a Problem Details response. AppDomain.CurrentDomain.UnhandledExceptionoffers last-chance observation before termination. It is not a recovery hook.TaskScheduler.UnobservedTaskExceptionreports a fault whose task exception was never observed. It does not make fire-and-forget work reliable.AppDomain.CurrentDomain.FirstChanceExceptionruns for every throw before handler selection, which makes it a diagnostic instrument rather than ordinary production logging.
Pitfalls
- Exceptions as routine branching. A cache miss or validation rejection may be an expected result. A
Try*method, nullable result, or explicit domain result makes that path visible and avoids paying for stack capture on normal traffic. throw ex;incatch: this restarts the stack trace at the rethrow point and loses the original call path.throw;inside the samecatchpreserves debugging context.- Throwing in
finally: a new exception fromfinallycan mask the original failure fromtry/catch. Keepingfinallyfocused on cleanup that raises no new exceptions prevents that masking. - Overly generic exception types: throwing
Exceptionmakes handling and telemetry less actionable. Specific built-in types (ArgumentException,InvalidOperationException, and related types) communicate intent.
Tradeoffs
- Exceptions versus result values. Exceptions carry unexpected failures naturally through .NET infrastructure. Result values make expected rejection paths explicit in signatures. The useful dividing line is whether the caller is expected to branch on the outcome.
- Catch width. A broad catch is appropriate at a host boundary that logs and translates failures. Deeper in the application, a catch belongs only where the failure can be handled, enriched, or rolled back meaningfully.
- Exception filters. A
whenfilter runs before stack unwinding and can select a handler without catching and rethrowing. Filter logic needs to stay predictable. A filter that returnsfalseleaves handler search to continue.
Questions
When should a .NET method throw an exception instead of returning a failure result?
A method throws when it cannot deliver what it promises and callers are not expected to branch on that outcome in normal flow. It returns a failure result when the outcome is common and expected, so callers routinely check it.
Aspect Throwing an exception Returning a failure result Fits when The failure is unexpected for this call, such as an invalid argument, an invalid object state or an unavailable dependency. The outcome is part of normal flow, such as a cache miss, unparsable user input or a rejected validation. If the caller ignores it A synchronous throw propagates up the call stack until a handler catches it, so the caller cannot skip it by not checking. A Task-returning async method stores the exception on thatTask; it surfaces only when the code that owns the task awaits or otherwise observes it, so an ignored faulted task can go unnoticed.The failure can be silently ignored if the caller never checks the result. Cost when it happens Throwing and unwinding are expensive, which matters when the outcome occurs on every request. Reporting the outcome costs about the same as success. Typical .NET shape A specific exception type such as ArgumentExceptionorInvalidOperationException.A Trymethod such asint.TryParsereturningbool,nullor default for a common miss, or an explicit result type.Many APIs offer both forms:
int.Parsethrows on bad input, whileint.TryParsereturnsfalse. The caller then picks the form that matches how often failure is expected.Microsoft: Best practices for exceptions · Microsoft: TaskScheduler.UnobservedTaskException
What is the difference between
throw;andthrow ex;inside acatchblock?
throw;rethrows the current exception while preserving its original stack trace.throw ex;starts the visible stack at the rethrow site and loses the original path. Microsoft: exception handling statements
When should an exception be wrapped instead of rethrown directly?
An exception should be wrapped when a boundary must add meaning that the original lacks: context about the operation that failed, or translation into a stable failure type that callers of that layer are expected to handle. The original goes into the new exception’s
InnerException, so its type, message and stack trace stay available.For example, a data-access layer can translate a database provider’s exception into a domain-level
OrderStoreUnavailableException. Callers then handle one stable type and do not depend on the provider.When the catch block only logs, cleans up or decides it cannot handle the failure,
throw;rethrows the original with its stack trace intact. Wrapping without adding meaning only lengthens the exception chain. It can also change which outer handlers match. Acatchmatches its declared type and types derived from it, so a handler that caught the original stops matching when the wrapper’s type is neither that type nor derived from it.Microsoft: Best practices for exceptions · Microsoft: Exception-handling statements
Why is throwing from
finallydangerous?An exception that escapes
finallywhile another exception is already propagating replaces that exception. The original stops propagating, so callers, outer handlers and any later logging see only the cleanup error, which usually says little about the real cause.For example, a
tryblock fails with a timeout, and closing a connection infinallythen throws an exception that escapes the block. The caller receives the close failure, and the timeout no longer appears in the propagated exception.Cleanup in
finallyis therefore kept simple, and any failure it can raise is caught and logged inside thefinallyblock so the original exception keeps propagating.Microsoft: Exception-handling statements · Microsoft: Best practices for exceptions
When might
finallynot execute?
finallyruns when control leaves itstryblock while the process keeps executing: normal completion, a jump such asreturnorbreak, or an exception unwinding through it. It does not run when the process ends without unwinding or when control never leaves thetryblock.
- Immediate process termination:
Environment.FailFastends the process without running pendingfinallyblocks or finalizers. A stack overflow, or the process being killed by the operating system or an orchestrator, ends it just as abruptly.- Unhandled exceptions: cleanup is reliable when a handler exists and the stack unwinds to it. If no handler is found, whether
finallyblocks run depends on how the runtime and operating system end the process.- Control never leaves the
tryblock: an infinite loop or a deadlock inside it meansfinallyis never reached.
finallyis therefore in-process cleanup, not crash recovery. State that must survive a crash relies on durable mechanisms such as transactions or recovery logic that runs on restart.Microsoft: Exception-handling statements · Microsoft: Environment.FailFast