Why exception handling is a code quality signal
How a developer handles exceptions reveals a lot about their maturity. Swallowing
exceptions, using throw ex (losing the stack trace), catching Exception without
logging — these mistakes make debugging production incidents dramatically harder.
Interviews probe exception handling because it's a window into a candidate's reliability
instincts.
The exception hierarchy — what to catch and what not to
Exception
├── SystemException (runtime — often unrecoverable)
│ ├── NullReferenceException
│ ├── InvalidOperationException
│ ├── ArgumentException
│ │ ├── ArgumentNullException
│ │ └── ArgumentOutOfRangeException
│ ├── IOException
│ │ └── FileNotFoundException
│ ├── OutOfMemoryException ← do NOT catch
│ └── StackOverflowException ← process terminates; unreachable
└── ApplicationException ← deprecated; avoid as base class
Rule: Catch the most specific type you can handle. Only catch Exception as a
last resort — log and re-throw. Never catch OutOfMemoryException or
StackOverflowException.
throw vs throw ex — the most common mistake
// throw ex — LOSES the original stack trace:
try { Inner(); }
catch (Exception ex) { throw ex; } // stack trace now points to this catch block!
// throw — PRESERVES the original stack trace:
try { Inner(); }
catch (Exception ex)
{
logger.LogError(ex, "Inner failed");
throw; // re-throws the same exception with original stack trace intact
}
// Wrapping — preserve original as InnerException:
try { Inner(); }
catch (DatabaseException ex)
{
throw new RepositoryException("Failed to save order", ex); // original in InnerException
}
When debugging a production incident, the stack trace is your map. throw ex tears
that map in half. Use bare throw unless you have a specific reason to wrap.
finally — cleanup that always runs
FileStream? fs = null;
try
{
fs = File.OpenRead("data.txt");
return Process(fs);
}
catch (IOException ex)
{
logger.LogError(ex, "File read failed");
throw;
}
finally
{
fs?.Close(); // always runs: success, exception, or return
}
// Modern equivalent — prefer 'using' for IDisposable:
using var stream = File.OpenRead("data.txt"); // Dispose called automatically
return Process(stream); // even if Process throws, stream is disposed
finally does not run for StackOverflowException or Environment.FailFast() — the
process terminates immediately in both cases.
Custom exceptions — when and how
Create a custom exception when callers need to catch your specific error programmatically, separate from other errors. Don't create one just for a custom message.
// Well-designed custom exception:
public class PaymentDeclinedException : Exception
{
public string DeclineCode { get; }
public string LastFour { get; }
// Standard constructors required for serialisation + chaining:
public PaymentDeclinedException() { }
public PaymentDeclinedException(string message) : base(message) { }
public PaymentDeclinedException(string message, Exception inner)
: base(message, inner) { }
// Domain-specific constructor:
public PaymentDeclinedException(string declineCode, string lastFour)
: base($"Payment declined ({declineCode}) for card ending {lastFour}")
{
DeclineCode = declineCode;
LastFour = lastFour;
}
}
// Catch specifically:
try { await ProcessPaymentAsync(card, amount); }
catch (PaymentDeclinedException ex)
{
// Code and last four digits are available — no message parsing needed
await NotifyUserAsync(ex.DeclineCode, ex.LastFour);
}
For simple input validation in .NET 6+, use the built-in throw-helpers:
public void SetAge(int age)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(age);
ArgumentOutOfRangeException.ThrowIfGreaterThan(age, 150);
_age = age;
}
AggregateException — multiple failures from parallel work
Task.WhenAll and Parallel.ForEach wrap multiple exceptions in AggregateException:
var t1 = ProcessAsync(item1); // might throw
var t2 = ProcessAsync(item2); // might throw
try
{
await Task.WhenAll(t1, t2);
}
catch
{
// 'await' re-throws only the FIRST inner exception
// To inspect ALL exceptions:
foreach (var t in new[] { t1, t2 }.Where(t => t.IsFaulted))
{
var inner = t.Exception!.InnerException!;
logger.LogError(inner, "Task failed: {Message}", inner.Message);
}
}
// Parallel.ForEach:
try
{
Parallel.ForEach(items, item => ProcessItem(item));
}
catch (AggregateException agg)
{
// Handle selectively — return true = handled; false = re-throw
agg.Handle(ex =>
{
if (ex is ValidationException ve)
{
logger.LogWarning(ve, "Validation error");
return true; // handled — don't rethrow
}
return false; // unhandled — will be re-thrown
});
}
Exception filters (when clause)
A when clause adds a condition to a catch block without unwinding the stack if it
evaluates to false. This means the exception can be caught by a handler further up:
// Only catch specific HTTP errors:
try { var data = await client.GetAsync(url); data.EnsureSuccessStatusCode(); }
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null; // treat as "not found"
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ServiceUnavailable)
{
throw new RetryableException("Service unavailable", ex);
}
// Other HttpRequestExceptions propagate naturally
// Log without catching — filter that always returns false:
catch (Exception ex) when (LogAndReturnFalse(ex)) { /* unreachable */ }
bool LogAndReturnFalse(Exception ex)
{
logger.LogError(ex, "Unhandled exception");
return false; // exception continues to propagate — stack trace preserved!
}
The when filter runs before the stack unwinds — this is why it's preferred for
logging: you see the full stack in the log, not just up to the catch block.
ExceptionDispatchInfo — capturing exceptions across threads
// Capture an exception with its full stack trace:
ExceptionDispatchInfo? captured = null;
var thread = new Thread(() =>
{
try { RiskyWork(); }
catch (Exception ex)
{
captured = ExceptionDispatchInfo.Capture(ex);
}
});
thread.Start();
thread.Join();
// Re-throw on the calling thread — original stack trace preserved:
captured?.Throw();
This is what await uses internally: when a Task faults, the exception is captured as
ExceptionDispatchInfo and re-thrown with the original stack trace when you await the
Task. You rarely need this directly.
Global exception handling in ASP.NET Core
.NET 8+ — IExceptionHandler (preferred)
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
=> _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
_logger.LogError(exception, "Unhandled exception for {Path}", context.Request.Path);
(int status, string title) = exception switch
{
NotFoundException => (404, "Not Found"),
ValidationException => (422, "Validation Error"),
UnauthorizedException => (401, "Unauthorized"),
_ => (500, "Internal Server Error")
};
context.Response.StatusCode = status;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = status,
Title = title,
Detail = exception.Message
}, ct);
return true; // exception handled
}
}
// Register in Program.cs:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
Pre-.NET 8 — UseExceptionHandler middleware
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var ex = feature?.Error;
logger.LogError(ex, "Unhandled exception");
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new { error = "Internal server error" });
});
});
throw expressions (C# 7)
// Null-coalescing throw:
_repo = repo ?? throw new ArgumentNullException(nameof(repo));
// In ternary:
string GetName(User? u) => u != null ? u.Name : throw new ArgumentNullException(nameof(u));
// In expression-bodied member:
public string Name
{
set => _name = value ?? throw new ArgumentNullException(nameof(value));
}
// .NET 6+ throw helpers — even cleaner:
ArgumentNullException.ThrowIfNull(repo);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
checked vs unchecked arithmetic
int max = int.MaxValue;
// Unchecked (default): wraps silently
int overflow = max + 1; // -2,147,483,648 — no exception!
// Checked: throws OverflowException
checked
{
int safe = max + 1; // OverflowException
}
// Hash code computation intentionally overflows — use unchecked:
unchecked
{
int hash = 17;
hash = hash * 31 + value.GetHashCode();
return hash;
}
When to throw vs return an error
| Situation | Prefer |
|---|---|
| Programming error (bug), invalid state | throw |
| Expected failure in a control-flow | return bool / Result<T> / null |
| Async — always | Store in Task, re-throw on await |
| Validation at API boundary | ValidationException or ProblemDetails |
| Recoverable business failure | Return a Result/discriminated union |
Libraries like FluentValidation and ErrorOr provide Result types for richer
error handling without exception overhead in hot paths.
Recap
Use bare throw to re-throw — never throw ex. The stack trace is your debugging
lifeline: preserve it. Use finally or using for cleanup. Design custom exceptions
only when callers need programmatic distinction; include domain context as typed properties.
AggregateException wraps parallel task failures — inspect individual Task exceptions for
full coverage. when filters add conditions without unwinding the stack, making them ideal
for selective catching and "log and rethrow" patterns. In ASP.NET Core, centralise HTTP
error mapping in a global IExceptionHandler rather than scattering status-code decisions
across controllers. Use ArgumentNullException.ThrowIfNull and sibling helpers (.NET 6+)
for clean input validation at method boundaries.