Skip to content

Exception Design

Key Points

  • Exceptions are for exceptional flow, not for normal control flow. If a condition happens often, return a Result<T> or bool + out, not an exception.
  • Throw the most specific exception that fits. Custom hierarchies live or die by whether callers can catch them meaningfully.
  • throw; preserves the original stack trace; throw ex; resets it. Use ExceptionDispatchInfo.Throw() to re-throw a previously-captured exception with full trace fidelity.
  • OperationCanceledException is the idiomatic cancellation signal. TaskCanceledException is a subclass for Task-style cancellation. Catch OperationCanceledException (parent) when you want to handle either.
  • [StackTraceHidden] removes the marked method's frame from stack traces — useful for framework helpers (e.g., ThrowHelper).
  • Exception filters (when)catch (X ex) when (predicate) — evaluate the predicate without unwinding the stack. Better debugging than catch-and-rethrow.
  • finally always runs (except for stack overflow / FailFast / unhandled exception in .NET Framework). Use for cleanup; never throw from a finally without good reason.

Concepts (deep dive)

Exception ≠ control flow

Exceptions in .NET are expensive: capturing the stack, walking the call stack to find a handler, marshaling exception data. A throw + catch pair can cost microseconds — fine for genuinely exceptional events, ruinous if used for "user not found" in a hot loop.

Rule of thumb: if the condition happens during normal operation — even infrequently — model it as a return value, not an exception.

// ❌ Exception for normal flow
public Customer Get(int id)
{
    var c = _db.Find(id);
    if (c is null) throw new CustomerNotFoundException(id);
    return c;
}

// ✅ Result type
public Result<Customer> Get(int id)
{
    var c = _db.Find(id);
    return c is not null ? Result.Ok(c) : Result.NotFound();
}

This isn't dogma — interfaces sometimes preclude it (IDictionary<TKey, TValue>.Item[get] throws on missing key), and "throw on misuse" is fine. But for normal predicate outcomes, return values are clearer and faster.

See also: Result Pattern vs Exceptions.

The exception hierarchy

                            System.Exception
            ┌────────────┬────────┴────────┬──────────────┐
            │            │                 │              │
   SystemException    Application    AggregateException ApplicationException
   ↑                  Exception            ↑              (legacy, avoid)
   │  (most BCL                            (Task-related,
   │   exceptions             multiple inner exceptions)
   │   live here)
   ├─ ArgumentException
   │     ├─ ArgumentNullException
   │     └─ ArgumentOutOfRangeException
   ├─ InvalidOperationException
   │     └─ ObjectDisposedException
   ├─ NotSupportedException
   ├─ NotImplementedException
   ├─ NullReferenceException        (don't throw; runtime emits)
   ├─ IndexOutOfRangeException      (don't throw; runtime emits)
   ├─ OverflowException
   ├─ FormatException
   ├─ IOException, …
   └─ OperationCanceledException
         └─ TaskCanceledException

Senior heuristics:

  • Never throw Exception, SystemException, ApplicationException, NullReferenceException, IndexOutOfRangeException. These are reserved for the runtime or are too generic to be useful.
  • Prefer specific BCL exceptions before inventing your own: ArgumentException, InvalidOperationException, NotSupportedException.
  • Custom exceptions should derive from Exception (not ApplicationException — that's a long-deprecated guidance).

Throwing properly

public void Save(Customer customer)
{
    ArgumentNullException.ThrowIfNull(customer);
    if (string.IsNullOrEmpty(customer.Name))
        throw new ArgumentException("Name is required.", nameof(customer));
    if (_disposed)
        throw new ObjectDisposedException(nameof(MyService));
    if (!_initialized)
        throw new InvalidOperationException("Initialize() must be called first.");
    // …
}

Notice:

  • ArgumentNullException.ThrowIfNull(x) (since .NET 6) is the modern idiom. Implies nameof(x) automatically. There's also ArgumentException.ThrowIfNullOrEmpty, ArgumentOutOfRangeException.ThrowIfLessThan, etc.
  • Always include the parameter name in argument exceptions.
  • Add a descriptive message. The user/log will thank you.

throw vs throw ex

try { /* ... */ }
catch (Exception ex)
{
    Log(ex);
    throw;          // ✅ preserves original stack trace
    // throw ex;    // ❌ resets the stack trace; you lose the call site
}

If you need to re-throw an exception you captured earlier (e.g., from another thread), use ExceptionDispatchInfo:

ExceptionDispatchInfo? captured = null;
try
{
    /* ... */
}
catch (Exception ex)
{
    captured = ExceptionDispatchInfo.Capture(ex);
}

if (captured is not null)
    captured.Throw();   // re-throws with original stack trace preserved

Task and async/await use ExceptionDispatchInfo internally — that's why await-ing a faulted task gives you the right stack.

Exception filters: when

try
{
    /* ... */
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    return null;
}
catch (HttpRequestException ex) when (ex.StatusCode >= HttpStatusCode.InternalServerError)
{
    throw new ServiceUnavailableException("upstream", ex);
}

Filters evaluate the predicate without unwinding the stack. If the filter is false, the catch is skipped and the exception keeps propagating. This is invisible in source but means: debugger first-chance breakpoint shows you the exception at the throw site, not the catch site.

Idioms with filters:

// "Catch-all that logs, but never swallows"
catch (Exception ex) when (LogAndKeep(ex))
{
    /* never reached: LogAndKeep returns false */
}

private static bool LogAndKeep(Exception ex)
{
    Logger.Error(ex, "unhandled");
    return false;   // exception keeps propagating
}

💡 Senior insight: filters can hide async/await issues — if the filter expression itself throws, the original exception is replaced. Keep filters simple and pure.

OperationCanceledException vs TaskCanceledException

Exception
  └ OperationCanceledException     ← idiomatic cancellation; thrown by ThrowIfCancellationRequested
      └ TaskCanceledException      ← thrown by Task framework (Task.WaitAsync timeout, etc.)

When you write a method that supports cancellation:

public async Task DoAsync(CancellationToken ct)
{
    while (true)
    {
        ct.ThrowIfCancellationRequested();
        await StepAsync(ct);
    }
}

ThrowIfCancellationRequested throws OperationCanceledException. When Task.Delay(timeout, ct) is canceled by the token, it throws OperationCanceledException with a CancellationToken set on the exception.

Catch OperationCanceledException to handle both:

try
{
    await DoAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // Expected; handle quietly.
}

The when filter ensures we only swallow our cancellation, not someone else's.

[StackTraceHidden]

internal static class ThrowHelper
{
    [StackTraceHidden]
    public static void ThrowIfNullOrEmpty(string? value, string paramName)
    {
        if (string.IsNullOrEmpty(value))
            throw new ArgumentException("required", paramName);
    }
}

[StackTraceHidden] removes the method's frame from Exception.StackTrace. Use for thin throw helpers so users see the caller's line, not your helper's.

The BCL uses this extensively — that's why ArgumentNullException.ThrowIfNull(x) shows the caller's line in the stack trace.

Custom exceptions

public sealed class OrderNotFoundException : Exception
{
    public Guid OrderId { get; }

    public OrderNotFoundException(Guid id)
        : base($"Order '{id}' not found.")
        => OrderId = id;

    public OrderNotFoundException(Guid id, Exception inner)
        : base($"Order '{id}' not found.", inner)
        => OrderId = id;
}

Modern guidance: - sealed unless you really intend to be inherited. - Public constructors with at least: (), (string message), (string message, Exception inner). The (id, message) overload pattern as above is fine for adding context. - Don't add [Serializable] unless you actually need it — binary serialization is deprecated in modern .NET. - No protected (SerializationInfo, StreamingContext) constructor — that's the deprecated pattern.

finally and resource cleanup

FileStream? f = null;
try
{
    f = File.OpenRead(path);
    Process(f);
}
finally
{
    f?.Dispose();
}

using is the better idiom:

using var f = File.OpenRead(path);
Process(f);

using lowers to try/finally with Dispose. Better still: await using for IAsyncDisposable.

⚠️ Don't throw from finally. If an exception is propagating from try, throwing in finally replaces it — you lose the original exception. (You can build aggregate exceptions if needed.)

AggregateException

Created when multiple exceptions need to be reported together — most commonly from Task.WhenAll or Parallel.For:

try
{
    await Task.WhenAll(t1, t2, t3);   // throws ONE AggregateException with all faults
}
catch (AggregateException ae)
{
    foreach (var inner in ae.InnerExceptions)
        Log(inner);
}

Note: await on a single faulted task unwraps to the inner exception. await Task.WhenAll(...) returns the first inner; the wrapper still aggregates if you access .Exception.

Order of catch blocks

try { /* ... */ }
catch (FileNotFoundException ex) { /* most specific */ }
catch (IOException ex)            { /* less specific */ }
catch (Exception ex)              { /* catch-all */ }

Order matters: the runtime walks them top-to-bottom and uses the first match. Putting Exception first means the more specific ones never fire.

The compiler warns on unreachable catches.


How it works under the hood

When throw executes:

  1. The runtime captures the current stack (lazy by default; populated when StackTrace is accessed).
  2. The runtime walks the stack looking for a handler. For each frame:
  3. It consults the JIT-published EH info for the method.
  4. If a catch handler matches the exception's type, it's chosen.
  5. If a when filter is present, the runtime calls the filter; only if it returns true is the catch chosen.
  6. While searching, the runtime also walks finally blocks and runs them.
  7. Once a handler is chosen, the stack unwinds (locals are released, finally blocks run on the way) and the catch executes.

Exception filters preserve the original stack (the search runs the filter before unwinding). This is why filters give better debugging — first-chance break shows you the throw site.

ExceptionDispatchInfo saves the captured stack and re-throws with it intact. Internally, Task.GetAwaiter().GetResult() on a faulted task uses this to give you a proper async stack trace.


Code: correct vs wrong

❌ Wrong: catching Exception to "handle errors"

try { DoWork(); }
catch (Exception ex)            // ❌ catches OutOfMemoryException, StackOverflowException, etc.
{
    Log(ex);                     // best case: log it
    return Result.Fail();       // worst case: silently mask a critical failure
}

✅ Correct: catch what you can handle; let the rest propagate

try { DoWork(); }
catch (TimeoutException ex)
{
    return Result.Fail("timeout");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    return Result.NotFound();
}
// Anything else propagates; the host's exception handler logs it.

❌ Wrong: throw ex; resetting the stack

try { DoWork(); }
catch (Exception ex)
{
    Log(ex);
    throw ex;   // ❌ stack trace now starts here, not at the original throw
}

✅ Correct: throw;

try { DoWork(); }
catch (Exception ex)
{
    Log(ex);
    throw;   // ✅ original stack preserved
}

❌ Wrong: ignoring OperationCanceledException

public async Task LoopAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        try { await StepAsync(ct); }
        catch (Exception ex) { Log(ex); }   // ❌ swallows OperationCanceledException
    }
}

✅ Correct: let cancellation propagate (or filter it)

public async Task LoopAsync(CancellationToken ct)
{
    try
    {
        while (true)
        {
            ct.ThrowIfCancellationRequested();
            await StepAsync(ct);
        }
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        // expected
    }
}

❌ Wrong: throwing in finally

try { DoWork(); }
finally
{
    if (!_initialized) throw new InvalidOperationException();   // ❌ replaces any in-flight exception
}

Design patterns for this topic

Pattern 1 — "Throw fast at boundaries; return values inside"

  • Intent: validate inputs at the public-API boundary; internal flow uses non-throwing returns.
  • When to use it: application services with lots of internal helpers.
  • Code sketch:
public Result<Order> Place(string? customerId, decimal amount)
{
    ArgumentException.ThrowIfNullOrEmpty(customerId);
    if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
    // From here on, internal helpers return Result<T>.
    return _placement.Place(customerId, amount);
}

Pattern 2 — "Exception hierarchy reflects retryability"

  • Intent: callers can categorize errors quickly.
  • When to use it: distributed systems, integration code.
  • Code sketch:
public abstract class TransientFailureException : Exception { /* retryable */ }
public abstract class PermanentFailureException : Exception { /* don't retry */ }

public sealed class ServiceUnavailableException : TransientFailureException { /* ... */ }
public sealed class InvalidPayloadException     : PermanentFailureException { /* ... */ }

Pattern 3 — "Throw helpers for repeated guards"

  • Intent: less code, cleaner stacks, single throw site.
  • Code sketch:
internal static class Guard
{
    [StackTraceHidden]
    public static T NotNull<T>([NotNull] T? value, [CallerArgumentExpression(nameof(value))] string? name = null)
        => value ?? throw new ArgumentNullException(name);

    [StackTraceHidden]
    public static int Positive(int value, [CallerArgumentExpression(nameof(value))] string? name = null)
        => value > 0 ? value : throw new ArgumentOutOfRangeException(name, value, "must be > 0");
}

// Use:
Guard.NotNull(input);
Guard.Positive(count);

[CallerArgumentExpression] (C# 10+) auto-fills the parameter name from the call site — Guard.NotNull(myArg) reports "myArg".

Pattern 4 — "Filter to handle, not to peek"

  • Intent: use when to differentiate handlers, not to do work.
  • When to use it: distinguishing exception subtypes by data (status code, error code).
  • Anti-pattern to avoid: catch (Exception ex) when (LogAndReturn(false, ex)) — works but obfuscates.

Pattern 5 — "Aggregate exceptions for batch failures"

  • Intent: collect many failures rather than fail-fast on the first.
  • When to use it: bulk operations where partial success matters.
  • Code sketch:
public Result<List<Order>> PlaceMany(List<OrderRequest> requests)
{
    var success = new List<Order>();
    var failures = new List<Exception>();
    foreach (var req in requests)
    {
        try { success.Add(Place(req)); }
        catch (Exception ex) { failures.Add(ex); }
    }
    if (failures.Count > 0)
        throw new AggregateException("some orders failed", failures);
    return Result.Ok(success);
}

Pros & cons / trade-offs

Approach Pros Cons
Exceptions for errors Clean unwinding; cross-layer signaling Expensive when frequent
Result types Cheap; explicit Verbose; some friction with framework APIs
Catch all (Exception) Simple Hides bugs; catches things you can't handle
Specific catches Localizes handling Can multiply if many types
Exception filters Better debugging; selective handling Filters that throw cause confusion
Throw helpers + StackTraceHidden Cleaner code, cleaner traces Requires care; helper bugs are hard to spot

When to use / when to avoid

  • Use exceptions for genuinely exceptional conditions: invariant violations, infrastructure failures, programmer errors.
  • Use Result<T> for outcomes that are part of the normal flow (not found, validation failed, business rule fired).
  • Use OperationCanceledException for cancellation; never invent your own cancellation exception.
  • Avoid catching Exception unless you re-throw or FailFast. The exceptions to this: top-level handlers in hosting infrastructure (which log and exit) and library code that explicitly converts unknown errors to a wrapper exception.
  • Avoid throwing in performance-critical paths. Restructure to a non-throwing API.

Interview Q&A

Q1. What's the difference between throw; and throw ex;? throw; re-throws the current exception preserving the original stack trace. throw ex; throws as if for the first time, resetting the stack to the current method. Always prefer throw;.

Q2. What's ExceptionDispatchInfo for? Captures an exception including its stack trace, allowing you to re-throw it later (possibly on a different thread) with the original trace preserved. Used internally by Task and async/await.

Q3. What does [StackTraceHidden] do? Removes the marked method's frame from Exception.StackTrace. Used on throw helpers so the caller's frame is what shows up in the trace.

Q4. What's the difference between OperationCanceledException and TaskCanceledException? TaskCanceledException is a subclass of OperationCanceledException. The former is what Task infrastructure throws (e.g., Task.WaitAsync(TimeSpan)); the latter is the broader idiom. Catch the parent if you want both.

Q5. Why is exception filtering with when better than catch-and-rethrow? Filters evaluate without unwinding. The debugger's first-chance breakpoint shows the original throw site. With catch-and-rethrow, the stack has already unwound by the time you decide to rethrow.

Q6. When should you create a custom exception class? When callers need to handle this kind of failure differently from siblings, AND no BCL exception fits semantically. The bar is high — first try InvalidOperationException with a good message.

Q7. Why is throw new Exception("...") discouraged? Too generic. Callers can't catch it specifically without catching everything. Prefer a specific BCL exception or a custom one.

Q8. What's [CallerArgumentExpression]? A C# 10+ attribute on a parameter that captures the expression the caller wrote. Used in throw helpers like ArgumentNullException.ThrowIfNull(x) to auto-populate the parameter name. The compiler injects the literal text.

Q9. How does await interact with exception stack traces? await on a faulted task uses ExceptionDispatchInfo to throw the inner exception with the original stack preserved. Modern .NET produces async-aware stack traces (AsyncMethodBuilder cooperation) that read top-to-bottom across awaits.

Q10. Why is Exception.HResult sometimes useful? It's a 32-bit integer that for OS-related exceptions maps to Windows/Unix error codes. Useful when interop'ing with native APIs and you want to differentiate failures programmatically.

Q11. When would you throw NotSupportedException vs NotImplementedException? NotSupportedException says "this operation is not supported and never will be" (e.g., calling Position on a non-seekable stream). NotImplementedException says "I haven't done this yet" — implies temporary state, often a TODO.

Q12. What does the Data property on Exception give you? A dictionary for ad-hoc context. Sometimes useful for adding a few key/value pairs without a custom exception type. Most loggers serialize it. Don't put PII in there.

Q13. How do you handle "any exception" at the top of an HTTP request pipeline in ASP.NET Core? Use IExceptionHandler (.NET 8+) or UseExceptionHandler middleware. The handler receives the exception, logs it, and produces a ProblemDetails response. Don't catch Exception in your application code — let it bubble.


Gotchas / common mistakes

  • ⚠️ throw ex; in catch blocks — resets the stack. Use bare throw;.
  • ⚠️ Catching Exception in app code without a re-throw — masks bugs.
  • ⚠️ Exception filters that mutate state or do I/O — make debugging weird.
  • ⚠️ Throwing from finally — replaces any in-flight exception.
  • ⚠️ Custom exception class with no (string, Exception inner) constructor — breaks ExceptionDispatchInfo chains.
  • ⚠️ Inheriting from ApplicationException — long-deprecated guidance; just inherit Exception.
  • ⚠️ Throwing in async void — modern runtime crashes the process. Use async Task.
  • ⚠️ Ignoring OperationCanceledException in cancel-aware code — silently swallows cancellation.
  • ⚠️ Logging in a when filter — filter runs every time the exception type matches, even if the catch isn't taken.

Further reading