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
, notApplicationException
. 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 likex-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
No. Only around boundaries where you can handle meaningfully (e.g., API controllers, message handlers). Inside the domain, let exceptions bubble.
Exception
? At app boundaries, yes – convert to a proper response and log. Inside business code, prefer catching specific exceptions.
ApplicationException
recommended? No. It adds no value. Prefer your own domain exceptions deriving from Exception
with useful properties.
Result<T>
instead of throwing? Great for expected, frequent outcomes (e.g., validation). Use exceptions for unexpected or exceptional situations.
Use unit tests with Assert.ThrowsAsync<T>()
(or FluentAssertions Awaiting(...).Should().Throw<>()
) and verify logging/mapping behavior.
Usually not. Validation and not‑found are user faults; log at Warning without stack (or sample).
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.