9 tips for managing exceptions in C#

C# Exception Handling: 9 Best Practices + Examples

Learn 9 proven C# exception‑handling practices with clear code—from filters and async/await to ProblemDetails, cancellation, and logging.

.NET Fundamentals Best Practices·By amarozka · September 21, 2025

C# Exception Handling: 9 Best Practices + Examples

Have you ever stared at a 200‑line stack trace at 3 a.m. wondering where it all went wrong? If yes, you and I have been in the same war. In one of my early .NET projects I “caught everything” and logged nothing meaningful. The result? A production outage, an angry pager, and a long night. In this article I’ll show you the exception-handling playbook I wish I’d had then – 9 practical tips with concise C# snippets you can paste into real code today.

What you’ll get: a mental model, 9 field‑tested practices (with code), an ASP.NET Core middleware you can drop in, and a short checklist.

A 60‑second mental model

Think of your app as layers of responsibility:

  • Domain/Application: business rules; throw meaningful domain exceptions.
  • Infrastructure: translate low‑level failures (I/O, HTTP, DB) into domain or application exceptions.
  • Edge/Boundary (API/UI/Worker host): catch, map to user‑friendly responses (HTTP codes, messages), and log with context.

Exceptions should bubble up until a layer that can handle them semantically. Resist the urge to catch just because you can.

Tip #1 – Fail fast with guard clauses (don’t nest try/catch)

Guard early to keep code flat, readable, and safer. Use argument validation and domain invariants at the top of your methods.

public sealed class Money
{
    public string Currency { get; }
    public decimal Amount { get; }

    public Money(string currency, decimal amount)
    {
        // Guard clauses keep the happy path straight
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency is required", nameof(currency));
        if (amount < 0)
            throw new ArgumentOutOfRangeException(nameof(amount), amount, "Amount cannot be negative");

        Currency = currency.ToUpperInvariant();
        Amount = amount;
    }
}

Why it works: you fail fast where the cause is obvious, avoiding deep, noisy try/catch pyramids later. Also, don’t use exceptions for normal control flow. If a result can be absent and that’s expected, prefer Try patterns or OneOf/Result types.

Tip #2 – Throw the right exception (and design custom ones carefully)

Use the most specific BCL type you can: ArgumentNullException, InvalidOperationException, TimeoutException, OperationCanceledException etc. Only create a custom exception when you need a domain‑specific signal or extra data.

[Serializable]
public sealed class NotEnoughCreditException : Exception
{
    public Guid CustomerId { get; }
    public decimal Needed { get; }
    public decimal Available { get; }

    public NotEnoughCreditException(Guid customerId, decimal needed, decimal available)
        : base($"Customer {customerId} lacks credit: needs {needed}, has {available}")
    {
        CustomerId = customerId;
        Needed = needed;
        Available = available;
    }

    private NotEnoughCreditException(System.Runtime.Serialization.SerializationInfo info,
                                     System.Runtime.Serialization.StreamingContext context)
        : base(info, context)
    {
        CustomerId = Guid.Parse(info.GetString(nameof(CustomerId))!);
        Needed = info.GetDecimal(nameof(Needed));
        Available = info.GetDecimal(nameof(Available));
    }

    public override void GetObjectData(System.Runtime.Serialization.SerializationInfo info,
                                       System.Runtime.Serialization.StreamingContext context)
    {
        base.GetObjectData(info, context);
        info.AddValue(nameof(CustomerId), CustomerId.ToString());
        info.AddValue(nameof(Needed), Needed);
        info.AddValue(nameof(Available), Available);
    }
}

Pro tip: derive from Exception, not ApplicationException. Add properties for the data you’ll need in logs or responses.

Tip #3 – Preserve the stack trace: throw; vs throw ex; and wrapping

If you rethrow, always use throw; to keep the original stack:

try
{
    DoWork();
}
catch (Exception ex)
{
    logger.LogError(ex, "Failed to do work");
    throw; // preserves stack trace
}

Avoid throw ex; – it resets the stack, making debugging harder. If you need to add context, wrap and chain with InnerException:

try
{
    await repository.SaveAsync(entity, ct);
}
catch (DbUpdateException ex)
{
    throw new DataAccessException("Failed to save entity to the database", ex);
}

public sealed class DataAccessException : Exception
{
    public DataAccessException(string message, Exception inner) : base(message, inner) {}
}

Tip #4 – Don’t swallow exceptions (log with context or convert)

Empty catches are time bombs. Either handle (retry, compensate, default) or bubble.

try
{
    await emailSender.SendAsync(message, ct);
}
catch (SmtpException ex)
{
    // convert to a domain‑level signal the upper layers understand
    throw new NotificationFailedException("Email delivery failed", ex)
        { Channel = "smtp", Recipient = message.To };
}

Logging tip: Use scopes for correlation. The extra 2 lines on write save 2 hours on read.

using var scope = logger.BeginScope(new Dictionary<string, object>
{
    ["OrderId"] = order.Id,
    ["CorrelationId"] = correlationId
});

try
{
    await processor.ProcessAsync(order, ct);
}
catch (Exception ex)
{
    logger.LogError(ex, "Order processing failed");
    throw;
}

Tip #5 – Use exception filters and pattern matching to separate concerns

when filters keep the catch blocks clean and prevent catch‑then‑rethrow noise.

try
{
    await apiClient.GetCustomerAsync(id, ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
    // Turn a transport detail into a domain decision
    throw new CustomerNotFoundException(id, ex);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
    throw new ExternalServiceAuthException("CRM auth failed", ex);
}

You can also pattern‑match on exception types and data in a single switch expression when handling centrally (see middleware below).

Tip #6 – Async/await: don’t lose exceptions, don’t forget to await

Most bugs I debug in async code are one of these:

  • Forgotten await – exceptions are thrown on the returned Task, not at call site.
  • Fire‑and‑forget without supervision – background tasks crash silently.
  • Wrapping sync blocks with Task.Run and forgetting exception boundaries.

Correct patterns

// 1) Always await; propagate to the current context
try
{
    await service.DoAsync(ct);
}
catch (BusinessRuleException ex)
{
    // handle or map
}

// 2) If you must fire‑and‑forget, attach a supervisor
_ = Task.Run(async () =>
{
    try { await worker.RunAsync(ct); }
    catch (Exception ex) { logger.LogError(ex, "Background worker crashed"); }
});

// 3) In hosted services, let the host know
public sealed class PriceFeed : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            await foreach (var msg in feed.ReadAsync(stoppingToken))
            {
                await handler.HandleAsync(msg, stoppingToken);
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // graceful shutdown; don’t log as error
        }
        catch (Exception ex)
        {
            // This will crash the host – sometimes that's correct.
            // If not intended, handle and signal health instead.
            logger.LogCritical(ex, "PriceFeed crashed");
            throw;
        }
    }
}

Remember: exceptions inside a Task are observed when awaited. If you never await, you never observe.

Tip #7 – Treat cancellation as a first‑class flow, not an error

OperationCanceledException is not a failure when you requested it. Avoid logging it as error; it pollutes dashboards.

public async Task<byte[]> DownloadAsync(Uri uri, CancellationToken ct)
{
    using var response = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
    response.EnsureSuccessStatusCode();

    await using var stream = await response.Content.ReadAsStreamAsync(ct);
    using var ms = new MemoryStream();

    await stream.CopyToAsync(ms, ct);
    return ms.ToArray();
}

// upstream
try
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(TimeSpan.FromSeconds(5));
    var bytes = await downloader.DownloadAsync(uri, cts.Token);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    // caller canceled: fine, exit quietly
}

Also: in long loops, periodically check ct.ThrowIfCancellationRequested().

Tip #8 – Centralize handling at the boundary with ASP.NET Core middleware

Map exceptions to HTTP responses consistently (and keep controllers clean) using a simple middleware that produces ProblemDetails.

// Minimal, production‑ready enough to start with
public sealed class ProblemDetailsMiddleware : IMiddleware
{
    private readonly ILogger<ProblemDetailsMiddleware> _log;
    public ProblemDetailsMiddleware(ILogger<ProblemDetailsMiddleware> log) => _log = log;

    public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
    {
        try
        {
            await next(ctx);
        }
        catch (Exception ex)
        {
            var (status, title, type) = ex switch
            {
                ValidationException            => (StatusCodes.Status400BadRequest, "Validation failed", "https://httpstatuses.com/400"),
                NotEnoughCreditException       => (StatusCodes.Status409Conflict, "Not enough credit", "https://httpstatuses.com/409"),
                CustomerNotFoundException      => (StatusCodes.Status404NotFound, "Customer not found", "https://httpstatuses.com/404"),
                OperationCanceledException     => (StatusCodes.Status499ClientClosedRequest, "Request canceled", "about:blank"),
                _                              => (StatusCodes.Status500InternalServerError, "Unexpected error", "https://httpstatuses.com/500")
            };

            var pd = new ProblemDetails
            {
                Title = title,
                Type  = type,
                Status = status,
                Detail = ex.Message,
                Instance = ctx.TraceIdentifier
            };

            using (_log.BeginScope("TraceId: {TraceId}", ctx.TraceIdentifier))
            {
                if (status >= 500)
                    _log.LogError(ex, "{Title}", title);
                else if (ex is OperationCanceledException)
                    _log.LogInformation("{Title}", title);
                else
                    _log.LogWarning(ex, "{Title}", title);
            }

            ctx.Response.StatusCode = status;
            ctx.Response.ContentType = "application/problem+json";
            await ctx.Response.WriteAsJsonAsync(pd);
        }
    }
}

public static class ProblemDetailsExtensions
{
    public static IServiceCollection AddProblemDetailsMiddleware(this IServiceCollection services)
        => services.AddTransient<ProblemDetailsMiddleware>();

    public static IApplicationBuilder UseProblemDetails(this IApplicationBuilder app)
        => app.UseMiddleware<ProblemDetailsMiddleware>();
}

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetailsMiddleware();
var app = builder.Build();
app.UseProblemDetails();
app.MapGet("/demo", () => Results.Ok("hello"));
app.Run();

This keeps controller actions free of try/catch noise and standardizes error payloads. Adapt the mapping function to your domain.

Tip #9 – Make logs actionable (structure, correlation, sampling)

Good logs turn exceptions into diagnoses, not mysteries:

  • Structure: log key/value pairs, not string soup. Your future self will query by OrderId.
  • Correlation: include TraceId/CorrelationId (propagate via headers like x-correlation-id).
  • Sampling: for noisy, expected errors (e.g., timeouts on flaky networks), sample warnings; keep 500s at 100%.
  • PII: never log secrets or personal data. Mask IDs when needed.
using (_logger.BeginScope(new { CorrelationId = corrId, UserId = userId }))
{
    try
    {
        await payment.CaptureAsync(request, ct);
        _logger.LogInformation("Payment captured: {Amount} {Currency}", request.Amount, request.Currency);
    }
    catch (PaymentDeclinedException ex)
    {
        _logger.LogWarning(ex, "Payment declined by issuer: {Reason}", ex.Message);
        throw;
    }
}

Bonus pattern – Retry and circuit‑break only at the edges

Resilience belongs near I/O. Wrap transient failures (HTTP 5xx, timeouts) with policies (e.g., Polly) in the infrastructure layer. Don’t blanket‑retry business exceptions.

Pseudo‑example using Polly (policy creation only; call with policy.ExecuteAsync around I/O):

var retry = Policy
    .Handle<HttpRequestException>()
    .OrResult<HttpResponseMessage>(r => (int)r.StatusCode is >= 500 or 408)
    .WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt)));

Visual cheat‑sheet (print me)

[Throw]  --> Domain/App  -->  [Bubble]  -->  API Boundary  -->  [Map to ProblemDetails + Log]
  ^                 ^                 ^
  |                 |                 |
 Guard          Wrap & add       Filters & mapping
 clauses        context          (don’t swallow)

Paste‑ready checklist

  • Validate inputs with guard clauses; avoid deep nesting.
  • Use specific exception types; create custom ones only with extra context.
  • Use throw; to preserve stack; wrap when adding meaning.
  • Never swallow; either handle (retry/compensate) or bubble.
  • Use exception filters (when) to keep catches focused.
  • Always await tasks you care about; supervise fire‑and‑forget.
  • Treat OperationCanceledException as normal flow when caller canceled.
  • Centralize HTTP error mapping with ProblemDetails middleware.
  • Log as structured events with correlation IDs; sample noisy warnings.

FAQ: C# exception handling in practice

Do I need try/catch around every method?

No. Only around boundaries where you can handle meaningfully (e.g., API controllers, message handlers). Inside the domain, let exceptions bubble.

Should I catch Exception?

At app boundaries, yes – convert to a proper response and log. Inside business code, prefer catching specific exceptions.

Is ApplicationException recommended?

No. It adds no value. Prefer your own domain exceptions deriving from Exception with useful properties.

What about returning Result<T> instead of throwing?

Great for expected, frequent outcomes (e.g., validation). Use exceptions for unexpected or exceptional situations.

How do I test exception paths?

Use unit tests with Assert.ThrowsAsync<T>() (or FluentAssertions Awaiting(...).Should().Throw<>()) and verify logging/mapping behavior.

Should I log stack traces for 4xx?

Usually not. Validation and not‑found are user faults; log at Warning without stack (or sample).

How do I handle UnobservedTaskException?

Subscribe to TaskScheduler.UnobservedTaskException early to log as a last resort, but don’t rely on it for control – it’s a safety net.

Conclusion: Ship calmer nights by making failures boring

Well‑behaved exception handling isn’t flashy – it’s boring by design. You guard early, throw precisely, preserve context, and handle everything at the edges with consistent payloads and clean logs. Do that, and your next pager alert becomes a quick fix instead of a scavenger hunt.

Which of the nine tips will you adopt first – or which one saved you last time? Share your war stories and patterns in the comments.

Leave a Reply

Your email address will not be published. Required fields are marked *