Exceptions are for exceptional paths, but the way you handle them shapes your diagnostics, API behavior, and on-call stress levels. If exceptions are noisy, vague, or swallowed, you're flying blind when things go wrong.

Let's walk through a few practical patterns that keep C# exception handling clean and predictable.

Throw the most specific exception you can

throw new Exception("bad input") doesn't help callers much. Use a specific built-in type (or a custom domain exception) so intent is obvious.

public static decimal CalculateDiscount(decimal price, decimal percentage)
{
    if (price < 0)
        throw new ArgumentOutOfRangeException(nameof(price), "Price must be non-negative.");

    if (percentage is < 0 or > 1)
        throw new ArgumentOutOfRangeException(nameof(percentage), "Percentage must be between 0 and 1.");

    return price * percentage;
}

Now callers can catch ArgumentOutOfRangeException when they need to handle bad input differently from system failures.

Preserve stack traces when rethrowing

If you're inside a catch block and need upstream code to handle the same exception, rethrow with throw;.

try
{
    await _paymentGateway.ChargeAsync(request, cancellationToken);
}
catch (Exception)
{
    _logger.LogWarning("Payment charge failed; retry policy will handle it.");
    throw; // Keeps original stack trace
}

Avoid throw ex; here — that resets the stack trace and makes debugging harder.

Wrap low-level exceptions at boundaries

Inside infrastructure code, provider exceptions are fine. At service boundaries, convert them to domain-focused exceptions with context.

public sealed class OrderPersistenceException : Exception
{
    public OrderPersistenceException(string message, Exception innerException)
        : base(message, innerException) { }
}

public async Task SaveAsync(Order order, CancellationToken cancellationToken)
{
    try
    {
        _dbContext.Orders.Add(order);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }
    catch (DbUpdateException ex)
    {
        throw new OrderPersistenceException(
            $"Failed to persist order '{order.Id}'.",
            ex);
    }
}

The caller now gets domain language and the original exception via InnerException.

Use exception filters for targeted handling

Filters keep catch logic focused and avoid giant catch blocks.

try
{
    await _inventoryClient.ReserveStockAsync(sku, quantity, cancellationToken);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
{
    return ReservationResult.OutOfStock(sku);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    return ReservationResult.UnknownSku(sku);
}

This is often cleaner than catching once and switching inside the catch body.

Log once, close to ownership

A common anti-pattern is logging the same exception in every layer. That creates duplicate noise and hides the real signal.

A useful rule of thumb:

  • Inner layers: add context by wrapping exceptions when needed
  • Outer boundary (API, worker loop, command handler): log the final failure once

That keeps logs readable and still preserves full error history.

Final thought

Solid exception handling isn't about adding more try/catch. It's about making failure modes explicit: specific exception types, preserved stack traces, meaningful context, and clear ownership of logging.

If you do those four things consistently, production incidents get a lot less mysterious.