C# Exception Handling Best Practices: A Quick Reference

I’ve written about .NET exceptions twice before: once on exception handling best practices in general, and once on exception handling in asynchronous code. Both are long, and sometimes you just want the rule without the essay. This post is the short version: the practices from those articles plus a few newer ones, grouped by situation, each with a sentence or two on why. .NET has changed a lot since 2018, so where the older advice no longer holds, I’ve said so.

Catching and rethrowing

  • Catch only the exceptions you expect and can do something useful with. A broad catch (Exception) belongs at the top of the application, in the exception-handling middleware, a background worker’s loop or a message handler. It doesn’t belong in every method.
  • Never swallow an exception. An empty catch block hides the one clue you’ll need later. If ignoring an exception really is correct, add a comment explaining why.
  • Rethrow with throw;. throw ex; resets the stack trace to the current line, so the original failure point is lost.
  • Handle exceptions at the level that can make a decision. A low-level method might throw an IOException, but only the caller knows whether to retry, ask the user or give up. Where a lower-level exception would leak implementation details, translate it into one that makes sense at your layer, and pass the original as the inner exception.
  • Use exception filters (catch ... when) to catch only the cases you care about. The filter runs before the stack unwinds, and if it returns false, the exception continues untouched.
  • Log an exception once, where you handle it. Catching, logging and rethrowing at every layer turns one failure into five log entries. logger.LogError(ex, ...) already records the message, the stack trace and every inner exception, so there’s no need to log those properties separately. I go into this in more detail in designing methods for logging and exception handling.
try
{
    return await catalogClient.GetProductAsync(productId, ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    throw new ProductNotFoundException(productId, ex);
}

Throwing: validation and the right exception type

Validate preconditions at the top of a method and fail immediately, before bad input has a chance to do any damage. Since .NET 6, the argument exceptions have static helpers that make guard clauses one line each:

public Invoice CreateInvoice(Order order, int dueInDays, string currency)
{
    ArgumentNullException.ThrowIfNull(order);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(dueInDays);
    ArgumentException.ThrowIfNullOrWhiteSpace(currency);

    if (order.Status != OrderStatus.Confirmed)
    {
        throw new InvalidOperationException(
            $"Order {order.Id} must be confirmed before invoicing; it is {order.Status}.");
    }

    // ...
}
  • Throw ArgumentNullException, ArgumentOutOfRangeException or ArgumentException for bad arguments, and InvalidOperationException when the arguments are fine but the object is in the wrong state for the call.
  • Write messages for the developer who will read them in a log. Say what was expected, what actually happened, and include the identifiers needed to find the record.
  • Use Debug.Assert for conditions that should be impossible if your own code is correct. Asserts only run in Debug builds, so they don’t replace validation of input that comes from outside.
  • Property getters shouldn’t throw. Callers, debuggers and serializers all read properties expecting them to be safe. A setter may throw ArgumentException for an invalid value.

Cleaning up

Any resource that must be released (a connection, a file handle, a lock) belongs in a using statement or a finally block, so it’s released whether the code succeeds or throws. Since C# 8, a using declaration (using var stream = File.OpenRead(path);) disposes the resource at the end of the enclosing scope without adding another level of nesting. For types that implement IAsyncDisposable, use await using. Use a plain finally for cleanup that isn’t disposal, such as resetting a flag or releasing a SemaphoreSlim.

When not to throw

  • Don’t use exceptions for normal control flow. If a failure is expected and common, such as invalid user input or a key that might not exist, check for it. The Try pattern (int.TryParse, Dictionary.TryGetValue) returns a bool and gives the result through an out parameter.
  • Don’t catch an exception and return null instead. The caller then gets a NullReferenceException three methods later, with no hint of what originally went wrong. If “not found” is a normal outcome, make that explicit with the Try pattern, a nullable return type that’s documented as such, or a result type.
  • Result types make a failure part of the method’s signature, so the caller can’t ignore it. C# has no built-in discriminated unions yet, but libraries such as ErrorOr, FluentResults and OneOf fill the gap. A reasonable split is result types for expected failures, like validation errors or “not found”, and exceptions for everything unexpected.
  • Throwing is expensive compared with returning a value. .NET 9 made exception handling roughly two to four times faster, but a throw in a hot loop still costs far more than an if. Keep exceptions out of code that runs thousands of times per second.

Custom exceptions

Create a custom exception when callers need to catch that specific failure, or when it carries data they need. A ProductNotFoundException with a ProductId property tells the caller more than a generic NotFoundException:

public sealed class ProductNotFoundException : Exception
{
    public ProductNotFoundException(int productId, Exception? innerException = null)
        : base($"Product {productId} was not found.", innerException)
    {
        ProductId = productId;
    }

    public int ProductId { get; }
}

Two parts of the old advice are out of date. Derive from Exception, or from a more specific built-in type, and not from ApplicationException. Microsoft stopped recommending ApplicationException long ago because it adds nothing. And on .NET Core and .NET 5 or later, you no longer need [Serializable] or the serialization constructor. Binary serialization is obsolete, and .NET 8 marks the Exception(SerializationInfo, StreamingContext) constructor as obsolete (SYSLIB0051). You only need them if you still pass exceptions across .NET Framework remoting or AppDomain boundaries.

Exception handling in asynchronous code

  • await rethrows the original exception, not an AggregateException, so you can catch it exactly as you would in synchronous code. You only see AggregateException when you block with .Result or .Wait(), or when you read Task.Exception directly.
  • Don’t block on async code. .Result and .Wait() wrap the exception, waste a thread, and can deadlock in UI apps and classic ASP.NET, which have a synchronization context. ASP.NET Core has no synchronization context, so it won’t deadlock there, but under load you’ll run into thread pool starvation instead. In library code, ConfigureAwait(false) prevents the deadlock for callers who block anyway.
  • When you await Task.WhenAll, only the first exception is rethrown. To see all of them, keep a reference to the combined task:
Task all = Task.WhenAll(SyncInventoryAsync(ct), SyncPricesAsync(ct));
try
{
    await all;
}
catch
{
    if (all.Exception is { } aggregate)
    {
        foreach (Exception ex in aggregate.InnerExceptions)
        {
            logger.LogError(ex, "Sync step failed");
        }
    }
    throw;
}
  • Avoid async void except for event handlers. An exception thrown from an async void method can’t be caught by its caller. It goes straight to the synchronization context or the thread pool, and that usually crashes the process. Every async event handler should wrap its body in a try-catch:
private async void SaveButton_Click(object sender, EventArgs e)
{
    try
    {
        await SaveAsync();
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Saving failed");
        ShowError("Your changes couldn't be saved. Please try again.");
    }
}
  • Exceptions in fire-and-forget tasks disappear silently. Since .NET 4.5, an unobserved task exception no longer crashes the process. It’s raised on TaskScheduler.UnobservedTaskException when the task is garbage collected, and nothing else reports it. Either await the task, or catch and log inside it.
  • OperationCanceledException, along with TaskCanceledException, which derives from it, usually means the caller cancelled. That isn’t an error, so don’t log it as one. One trap: when HttpClient times out, it also throws TaskCanceledException, with a TimeoutException as the inner exception. Check ct.IsCancellationRequested to tell the two cases apart.
  • ContinueWith with TaskContinuationOptions.OnlyOnFaulted is how this was done before await. It still works, but it runs on TaskScheduler.Current by default, which can surprise you. In new code, a try-catch around await does the same job more clearly.

Application-level handling

  • Centralize it. In ASP.NET Core, register AddProblemDetails() and an IExceptionHandler (.NET 8 and later), then call UseExceptionHandler(). Every unhandled exception is then logged once and returned as a consistent Problem Details response, and controllers don’t need their own try-catch blocks. Because the handler is registered with dependency injection, it’s also the one place to map exception types to status codes.
  • Background services. Since .NET 6, an exception that escapes BackgroundService.ExecuteAsync is logged and then stops the whole host. Put a try-catch around each iteration of the worker loop, so one bad message doesn’t take down the service, but let OperationCanceledException through when the app is shutting down.
  • Last-chance logging. AppDomain.CurrentDomain.UnhandledException lets you log an exception that nothing else caught, but it can’t prevent the process from ending. Restarting the process is the job of whatever runs it: Kubernetes liveness probes, a Windows service’s recovery settings, or Azure App Service’s health checks.
  • Environment.FailFast is for situations where the process state is so corrupted that continuing would do more harm than good. It ends the process immediately, without running finally blocks or finalizers, and writes a message to the Windows event log. It’s the wrong tool for an ordinary failed precondition. Throw an exception for those.
  • Retries and circuit breakers. Transient failures such as network blips, timeouts, 429 or 503 responses and SQL deadlocks are worth retrying with exponential backoff and jitter. Use Polly v8, or Microsoft.Extensions.Http.Resilience for HttpClient. A circuit breaker stops calling a dependency that keeps failing, which prevents one outage from spreading into others. Retry only operations that are safe to repeat.
  • Error codes in APIs. Exceptions don’t cross process boundaries, but error codes do. Give each kind of failure a stable code, either as the Problem Details type URI or as an errorCode extension field. Clients can then branch on the code instead of parsing message text, which breaks the first time someone rewords a message.
  • Localization. Keep exception messages in one language, because developers and log searches depend on them. Translate at the edge instead: map the error code to a localized message with IStringLocalizer and resource files when you show it to a user.
  • What users see. Show users a short, calm message that tells them what they can do next. Leave out stack traces, SQL errors and internal type names. The trace ID in the response is enough for support to find the details in your logs.

Specific kinds of failure

  • Database: SqlException.Number tells you which error occurred. 1205 means your transaction was chosen as a deadlock victim, and -2 is a timeout. Both are worth retrying. Constraint violations are not. EF Core’s EnableRetryOnFailure() retries SQL Server’s transient errors for you. DbUpdateConcurrencyException means someone else changed the row first, so reload the data or tell the user. Don’t retry blindly.
  • Network: Since .NET 5, HttpRequestException.StatusCode gives you the response status code. Treat timeouts, connection failures and 5xx responses as possibly transient. A 4xx means the request itself was wrong, and sending it again won’t help.
  • I/O: A FileNotFoundException won’t fix itself, but an IOException caused by another process holding the file often clears up after a short wait. Handle the two differently.
  • Concurrency: Most race conditions don’t throw anything. They just corrupt state, so the fix is design (immutable data, locks, concurrent collections), not catch blocks. Parallel.ForEach and PLINQ collect failures in an AggregateException. Flatten() and Handle() help you work through it.
  • Security: An exception message can reveal file paths, SQL or user data. Make sure none of that reaches the client, and be careful what you log as well.
  • Third-party libraries: Read the library’s documentation for the exceptions it throws, and translate them at the boundary of your code. That way the rest of your application doesn’t depend on, say, a payment provider’s exception types.

Testing, documenting and monitoring

Test the failure paths as deliberately as the success paths. xUnit’s Assert.Throws and Assert.ThrowsAsync check both the exception type and its contents. To make a dependency fail, configure a mock (Moq or NSubstitute) to throw, so you can see how your code reacts to a database or HTTP error:

[Fact]
public void CreateInvoice_rejects_unconfirmed_orders()
{
    var order = new Order { Id = 7, Status = OrderStatus.Draft };

    var ex = Assert.Throws<InvalidOperationException>(
        () => service.CreateInvoice(order, 30, "EUR"));

    Assert.Contains("7", ex.Message);
}

The exceptions a public method throws are part of its contract. Document them with /// <exception cref="ProductNotFoundException"> so they appear in IntelliSense. Changing which exception a method throws is a breaking change for anyone catching it. If a new version needs a more specific exception, derive it from the one callers already catch, so their existing catch blocks still work. Write your exception-handling conventions down with the rest of your coding conventions, so the whole team handles failures the same way.

Finally, watch exceptions in production. Application Insights, or any OpenTelemetry back end, records them with their type and stack trace. Alert on the exception rate, and on exception types that first appear after a deployment. Those are the first signs that a release broke something.

Further reading

Best practices for exceptions | Microsoft Learn

Exception throwing guidelines | Framework Design Guidelines

Async/Await: Best Practices in Asynchronous Programming by Stephen Cleary

.NET Exceptions Best Practices: When to Catch or Throw (hamidmosalla.com)

Exception Handling in Asynchronous Code (hamidmosalla.com)

.NET Exceptions Best Practices | fullstackhero on Medium

Polly documentation

Share...
 

Hamid Mosalla

Hi, I'm Hamid ("Arman"). I'm a software developer with 8+ years of experience in C#, .NET Core, Software Architecture and Web Development. I enjoy creating dev tools, contributing to open-source projects, and sharing insights on my blog. Outside of tech, I’m into indie cinema, classical music and abstract art.

 

One thought on “C# Exception Handling Best Practices: A Quick Reference”

Leave a Reply

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