Back to Blog
C#

C# Exception Handling: Patterns for Robust Code

c# exception handling: Learn practical C# exception handling patterns: try-catch-finally, exception filters, custom exceptions, and performance tradeoffs.

Exception Handlingtry-catchCustom ExceptionsException FiltersC#Error Handling
Illustration of C# exception handling showing a try block with a catch block and an error icon.

c# exception handling requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

In C#, exception handling is built around the try-catch-finally construct, but using it effectively requires more than wrapping every method body in a try block. The language gives you several tools to control how exceptions flow through your code, and choosing the right pattern for each situation directly affects reliability, readability, and runtime cost.

The Core Try-Catch-Finally Structure

The fundamental syntax is straightforward: a try block contains code that might throw, a catch block handles a specific exception type, and an optional finally block runs regardless of whether an exception occurred. The finally block is the right place for cleanup that must happen no matter what, such as closing a file handle or releasing a lock.

FileStream stream = null; try { stream = File.OpenRead("data.txt"); // Process the file } catch (IOException ex) { Console.WriteLine($"I/O error: {ex.Message}"); } finally { stream?.Dispose(); }

Here, the catch block only handles IOException. Any other exception type, like UnauthorizedAccessException or ArgumentException, will propagate up the call stack. The finally block guarantees that the stream is disposed even if an unexpected exception escapes. This separation of concerns keeps cleanup logic close to the resource acquisition, which is preferable to relying on the caller to remember to dispose.

When to Catch and When to Let Exceptions Propagate

A common mistake is catching every exception at every layer. Catching an exception that you cannot meaningfully handle only obscures the original failure and makes debugging harder. The rule of thumb is to catch an exception when you can take a concrete recovery action, such as retrying an operation, providing a fallback value, or translating the exception into a more meaningful type for the caller.

If a method cannot do anything useful with an exception, it should let it propagate. For example, a data access layer might catch SqlException to log the failure and rethrow a domain-specific RepositoryException, but it should not catch OutOfMemoryException or StackOverflowException because those indicate a fatal condition. Similarly, catching Exception broadly at the top of a request pipeline is acceptable for logging, but only if you rethrow or handle it appropriately for the application boundary.

Using Exception Filters to Narrow Catch Blocks

C# 6 introduced exception filters, which let you specify a condition that must be true for a catch block to execute. This is more precise than catching a broad type and then checking a property inside the block, because the filter runs before the block is entered and does not unwind the stack prematurely.

try { // Some operation that may throw } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { // Only handle 404 responses } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.ServiceUnavailable) { // Handle 503 with retry logic }

Filters keep related logic together and avoid the awkward pattern of catching an exception, checking a condition, and rethrowing if it does not match. They also preserve the original stack trace because the exception is not caught and rethrown. When a filter evaluates to false, the runtime continues searching for a matching catch block, which is more efficient than manually rethrowing.

Designing Custom Exception Types

Creating a custom exception class is appropriate when you need to convey domain-specific error information that the built-in exception types do not capture. A well-designed custom exception should derive from Exception (or a more specific base like ApplicationException), provide the standard constructors, and expose additional properties that describe the error context.

public class OrderNotFoundException : Exception { public int OrderId { get; } public OrderNotFoundException(int orderId) : base($"Order {orderId} was not found.") { OrderId = orderId; } public OrderNotFoundException(int orderId, Exception innerException) : base($"Order {orderId} was not found.", innerException) { OrderId = orderId; } }

When you define a custom exception, keep the serialization contract in mind if the exception might cross process boundaries, such as in a distributed system. The standard pattern includes a protected constructor for serialization, although modern .NET often avoids binary serialization in favor of data contracts. More importantly, avoid creating a custom exception for every possible failure; only do so when the exception type itself carries actionable information for the caller.

Performance and Allocation Costs of Exceptions

Exceptions are not free. Throwing an exception involves allocating an exception object, capturing a stack trace, and unwinding the call stack. In hot paths where an error condition is expected and frequent, using exceptions for control flow can degrade performance significantly. The .NET runtime optimizes the common case where no exception is thrown, but the cost of throwing is still high compared to a simple conditional check.

Consider a method that validates input and returns a result. Instead of throwing an exception for an invalid value, you might return a result object or use the Try pattern, as seen with int.TryParse. This is not about avoiding exceptions entirely; it is about reserving them for exceptional, unexpected conditions. When you do throw, the runtime must allocate the exception object and build a stack trace, which involves walking the stack and formatting method names. In a high-throughput service, this overhead can become measurable.

Another subtle cost is the finally block execution. While finally blocks are cheap when no exception occurs, they do add a small amount of work to every normal exit path. The runtime has to track the state of the try block to know whether to run the finally logic. This overhead is usually negligible, but it is a reason not to wrap trivial code in try-finally unnecessarily.

Logging and Observability in Exception Handling

How you log exceptions is as important as how you catch them. When you log an exception, always log the full exception object, not just ex.Message. The stack trace and inner exceptions contain the diagnostic context needed to trace the root cause. Use a structured logging framework that preserves exception properties, so you can query by exception type or message in your log aggregation tool.

try { // Operation } catch (Exception ex) { _logger.LogError(ex, "Failed to process order {OrderId}", orderId); }

In the catch block, avoid swallowing exceptions without logging. If you catch an exception and do not rethrow, you are making a decision that the failure is non-fatal. That decision should be visible in logs. Also, be careful about logging at the wrong level. A recoverable error might be Warning, while an unhandled exception that crashes the request is Error or Critical. Consistent logging levels make it easier to set up alerts and dashboards.

When an exception propagates across layer boundaries, consider adding contextual information. This often means wrapping the exception in a new exception that includes the original as an inner exception. The pattern preserves the stack trace while adding higher-level context, such as the operation being performed or the user ID. However, do not over-wrap; too many nested exceptions make logs noisy and obscure the original failure.

Exception Handling in Asynchronous Code

Async and await introduce additional considerations. An exception thrown in an async method is captured and placed on the returned task. When you await that task, the exception is rethrown at the await point. This means you can use try-catch around an await expression just like you would around synchronous code.

try { await _client.SendAsync(request); } catch (HttpRequestException ex) { // Handle network error }

One common pitfall is not awaiting a task and then trying to catch exceptions. If you call an async method without awaiting it, the exception is stored in the task and will not be observed unless you await it or explicitly check the task's Exception property. Unobserved task exceptions can lead to TaskScheduler.UnobservedTaskException events, which are often ignored in modern .NET but still indicate a design flaw. Always await async calls or attach a continuation to handle failures.

Another subtlety is that exception filters work in async code as well, but you cannot use an await expression inside the filter. The filter must be a synchronous boolean expression. If you need to asynchronously determine whether to handle an exception, you have to catch it, perform the async check, and rethrow if the check fails. This is a limitation to be aware of when designing exception handling for async pipelines.

Choosing Between Exceptions and Result Types

In some applications, especially those with strict performance requirements or a functional programming style, developers prefer to use result types instead of exceptions for expected failures. A result type, such as Result<T> or OneOf, makes the failure path explicit in the method signature and avoids the overhead of exception throwing. This approach is common in domain-driven design and in APIs where validation errors are part of the normal flow.

The tradeoff is that result types require the caller to explicitly check for success or failure, which can lead to repetitive branching. Exceptions, on the other hand, propagate automatically and can be caught at a higher level, which reduces boilerplate for cross-cutting concerns like logging. There is no universal answer. Use exceptions for exceptional conditions that the caller cannot reasonably anticipate, and use result types for expected, recoverable failures such as validation errors or business rule violations.

A hybrid approach is also viable: throw exceptions for programmer errors and unexpected runtime conditions, but return result types for domain validation. This keeps the exception path clean and makes the expected failure modes visible in the code. When you do choose result types, be consistent across your codebase to avoid mixing paradigms in a way that confuses maintainers.

c# exception handling: Practical Usage and Code Examples | RYUSLOG DEV