Overview
Exception handling in C# is the mechanism used to detect, propagate, handle, and recover from runtime errors. It is built around try, catch, finally, throw, exception types, stack traces, and the .NET exception hierarchy.
In real applications, exception handling is used when an operation cannot complete its intended work. Examples include invalid arguments, unavailable files, failed database calls, network timeouts, permission failures, unexpected object state, and external service failures.
Exception handling matters because production applications must fail safely, release resources correctly, preserve useful diagnostic information, return appropriate API responses, and avoid hiding serious problems. Poor exception handling can make bugs harder to diagnose, corrupt application state, leak resources, or turn small failures into system-wide outages.
For interviews, exception handling is important because it tests both language knowledge and production judgment. Interviewers often expect candidates to understand not only syntax, but also when to catch exceptions, when to let them propagate, how to preserve stack traces, how async exceptions behave, how to handle cancellation, how to design custom exceptions, and how error handling works in ASP.NET Core applications.
Core Concepts
What an Exception Is
An exception is an object that represents an error or unexpected condition that occurs while a program is running.
In .NET, exceptions usually derive from System.Exception. When an exception is thrown, normal execution stops and the runtime searches for a matching catch block. If no matching handler is found, the exception continues up the call stack until it is handled or until the application boundary is reached.
Common exception types include:
ArgumentNullExceptionArgumentExceptionArgumentOutOfRangeExceptionInvalidOperationExceptionNotSupportedExceptionFileNotFoundExceptionIOExceptionUnauthorizedAccessExceptionTimeoutExceptionOperationCanceledException
Example:
public decimal Divide(decimal left, decimal right)
{
if (right == 0)
{
throw new DivideByZeroException("The divisor cannot be zero.");
}
return left / right;
}
In production code, prefer the most specific exception type that describes the failure.
The Basic try, catch, and finally Flow
A try block contains code that might fail. A catch block handles a specific exception. A finally block contains cleanup code that should run whether the operation succeeds or fails.
try
{
string text = File.ReadAllText("settings.json");
Console.WriteLine(text);
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"Configuration file was not found: {ex.FileName}");
}
catch (UnauthorizedAccessException ex)
{
Console.WriteLine($"Access denied: {ex.Message}");
}
finally
{
Console.WriteLine("File read attempt finished.");
}
Important rules:
- A
tryblock must have at least onecatchor onefinally. catchblocks are checked from top to bottom.- More specific exception types should come before more general exception types.
- Only one matching
catchblock runs for a thrown exception. finallyis commonly used for cleanup, althoughusingis usually better for disposable resources.
Catch Specific Exceptions
A common mistake is catching Exception everywhere.
try
{
ProcessOrder(order);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
This is usually too broad because it can hide programming bugs, infrastructure failures, and corrupted state. A better approach is to catch only exceptions you can handle meaningfully.
try
{
ProcessOrder(order);
}
catch (PaymentDeclinedException ex)
{
logger.LogWarning(ex, "Payment was declined for order {OrderId}.", order.Id);
return OrderResult.PaymentDeclined;
}
catch (InventoryUnavailableException ex)
{
logger.LogWarning(ex, "Inventory was unavailable for order {OrderId}.", order.Id);
return OrderResult.OutOfStock;
}
It is acceptable to catch a broad exception at application boundaries, such as:
- API middleware
- background worker top-level loops
- message handler boundaries
- CLI command boundaries
- logging and crash reporting boundaries
At those boundaries, the goal is usually to log the failure, return a safe response, and prevent sensitive implementation details from leaking to the user.
throw vs throw ex
When rethrowing an exception from inside a catch block, use throw;, not throw ex;.
Correct:
try
{
SaveCustomer(customer);
}
catch (SqlException ex)
{
logger.LogError(ex, "Failed to save customer {CustomerId}.", customer.Id);
throw;
}
Incorrect:
try
{
SaveCustomer(customer);
}
catch (SqlException ex)
{
logger.LogError(ex, "Failed to save customer {CustomerId}.", customer.Id);
throw ex;
}
throw; preserves the original stack trace. throw ex; resets the stack trace from the rethrow location, which makes debugging harder.
Wrapping Exceptions and Inner Exceptions
Sometimes a lower-level exception should be wrapped in a higher-level exception that better describes the business operation that failed.
public async Task<CustomerProfile> GetCustomerProfileAsync(Guid customerId)
{
try
{
return await repository.GetProfileAsync(customerId);
}
catch (SqlException ex)
{
throw new CustomerProfileLoadException(
$"Failed to load customer profile for customer '{customerId}'.",
ex);
}
}
The original exception should be passed as the inner exception. This preserves the root cause while adding context.
Good exception wrapping:
- Adds meaningful context.
- Preserves the original exception as
InnerException. - Uses a higher-level exception type when the lower-level type is not meaningful to the caller.
Poor exception wrapping:
- Catches and rethrows without adding value.
- Replaces the original exception and loses diagnostic information.
- Converts every exception into a generic exception type.
Exception Filters with when
Exception filters let you catch an exception only when a condition is true.
try
{
await httpClient.GetAsync(url);
}
catch (HttpRequestException ex) when (ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning(ex, "The request timed out.");
}
A more realistic example is filtering by status code:
try
{
await SendToExternalApiAsync(request);
}
catch (ExternalApiException ex) when (ex.StatusCode == 429)
{
logger.LogWarning(ex, "External API rate limit was reached.");
throw new RetryableExternalApiException("The external API rate limit was reached.", ex);
}
catch (ExternalApiException ex) when (ex.StatusCode >= 500)
{
logger.LogWarning(ex, "External API returned a server error.");
throw new RetryableExternalApiException("The external API is temporarily unavailable.", ex);
}
Exception filters are useful when the exception type is the same but the handling depends on additional data.
finally, using, and Resource Cleanup
finally is useful for cleanup code that must run even when an exception occurs.
FileStream? stream = null;
try
{
stream = File.OpenRead("data.txt");
// Read the file.
}
finally
{
stream?.Dispose();
}
However, for types that implement IDisposable or IAsyncDisposable, prefer using or await using.
using FileStream stream = File.OpenRead("data.txt");
// Use the stream.
For asynchronous disposal:
await using var connection = await CreateConnectionAsync();
// Use the connection.
using is clearer and less error-prone than manually writing try/finally for most resource cleanup.
Do Not Use Exceptions for Normal Control Flow
Exceptions are for exceptional or invalid conditions, not ordinary decision-making.
Poor approach:
try
{
int value = int.Parse(input);
Console.WriteLine(value);
}
catch (FormatException)
{
Console.WriteLine("Invalid number.");
}
Better approach for expected invalid input:
if (int.TryParse(input, out int value))
{
Console.WriteLine(value);
}
else
{
Console.WriteLine("Invalid number.");
}
Use exceptions when the method cannot complete its contract. Use validation, conditional checks, TryParse, TryGetValue, or result objects for expected outcomes.
Argument Validation
Public methods should validate their inputs and throw clear argument exceptions when the caller violates the method contract.
public Customer CreateCustomer(string name, string email)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentException.ThrowIfNullOrWhiteSpace(email);
if (!email.Contains('@'))
{
throw new ArgumentException("Email must be a valid email address.", nameof(email));
}
return new Customer(name, email);
}
Common argument exceptions:
- Use
ArgumentNullExceptionwhen a required argument isnull. - Use
ArgumentExceptionwhen an argument is invalid. - Use
ArgumentOutOfRangeExceptionwhen a value is outside the allowed range.
For modern C#, static throw helper methods such as ArgumentNullException.ThrowIfNull and ArgumentException.ThrowIfNullOrWhiteSpace make validation concise and consistent.
Exception Safety and State Consistency
A method should avoid leaving an object in an invalid or partially updated state when an exception occurs.
Risky example:
public void Transfer(Account from, Account to, decimal amount)
{
from.Balance -= amount;
// If this throws, money was removed but not added.
to.Balance += amount;
}
Better approach:
public void Transfer(Account from, Account to, decimal amount)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be positive.");
}
if (from.Balance < amount)
{
throw new InvalidOperationException("Insufficient funds.");
}
decimal originalFromBalance = from.Balance;
decimal originalToBalance = to.Balance;
try
{
from.Balance -= amount;
to.Balance += amount;
}
catch
{
from.Balance = originalFromBalance;
to.Balance = originalToBalance;
throw;
}
}
In real systems, transactions are often used to protect consistency.
Examples:
- SQL transaction for database updates.
- Message processing with retry and dead-letter handling.
- Unit of Work pattern in business applications.
- Outbox pattern for coordinating database changes and message publishing.
Async Exception Handling
Exceptions thrown inside an async method are stored in the returned Task and rethrown when the task is awaited.
public async Task<string> DownloadAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url);
}
try
{
string result = await DownloadAsync("https://example.com");
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Download failed.");
}
Important async exception rules:
- Exceptions before the first
awaitcan still be represented through the returned task in most async methods. - Awaiting a faulted task rethrows the exception.
- If a task is never awaited or observed, its exception may be missed.
async voidexceptions are difficult to handle and can crash application-level contexts.- Prefer
async Taskorasync Task<T>overasync void, except for event handlers.
Avoid fire-and-forget code unless it has its own exception handling:
_ = Task.Run(async () =>
{
try
{
await SendAuditLogAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send audit log.");
}
});
Task.WhenAll and Multiple Exceptions
Task.WhenAll waits for multiple tasks. If one or more tasks fail, the returned task is faulted.
Task[] tasks =
[
ImportCustomersAsync(),
ImportOrdersAsync(),
ImportInvoicesAsync()
];
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
logger.LogError(ex, "One or more imports failed.");
foreach (Task task in tasks.Where(t => t.IsFaulted))
{
logger.LogError(task.Exception, "A task failed.");
}
}
When awaiting Task.WhenAll, one exception is rethrown, but the task's Exception property can contain multiple inner exceptions. This is important in batch processing and parallel operations.
Cancellation Is Not the Same as Failure
Cancellation usually represents an intentional stop request, not a system failure.
Common cancellation types:
CancellationTokenOperationCanceledExceptionTaskCanceledException
Example:
public async Task<string> ReadFileAsync(string path, CancellationToken cancellationToken)
{
return await File.ReadAllTextAsync(path, cancellationToken);
}
Handling cancellation:
try
{
string content = await ReadFileAsync(path, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation("The operation was canceled by request.");
}
In most applications, cancellation should be logged at a lower severity than unexpected exceptions.
Custom Exceptions
Create a custom exception only when existing exception types do not clearly describe the failure and callers need to distinguish that failure programmatically.
public sealed class OrderCannotBeSubmittedException : Exception
{
public OrderCannotBeSubmittedException()
{
}
public OrderCannotBeSubmittedException(string message)
: base(message)
{
}
public OrderCannotBeSubmittedException(string message, Exception innerException)
: base(message, innerException)
{
}
public string? OrderId { get; init; }
}
Best practices:
- End the type name with
Exception. - Derive from
Exception. - Include common constructors.
- Add extra properties only when callers can use them programmatically.
- Avoid creating custom exceptions for every small validation rule.
Exceptions vs Result Objects
Exceptions and result objects solve different problems.
Use exceptions when:
- A method cannot complete its intended contract.
- The failure is unexpected or exceptional.
- The error should propagate to a higher-level boundary.
- You need stack trace diagnostics.
Use result objects when:
- Failure is expected and part of normal business flow.
- The caller should handle success and failure explicitly.
- You want to avoid exceptions for validation or predictable outcomes.
Example result object:
public sealed record CreateOrderResult(bool Succeeded, string? Error);
public CreateOrderResult CreateOrder(OrderRequest request)
{
if (request.Items.Count == 0)
{
return new CreateOrderResult(false, "An order must contain at least one item.");
}
return new CreateOrderResult(true, null);
}
Practical examples:
- Invalid login credentials are usually a result, not an exception.
- A missing required constructor argument is usually an exception.
- A database outage is usually an exception.
- A business rule failure may be a result or a domain exception depending on the architecture.
Logging Exceptions
When logging exceptions, include the exception object, not only the message.
Poor logging:
logger.LogError(ex.Message);
Better logging:
logger.LogError(ex, "Failed to process order {OrderId}.", order.Id);
Good exception logging should include:
- The exception object.
- A useful message with operation context.
- Correlation IDs or request IDs when available.
- Relevant business identifiers, such as order ID or customer ID.
- No sensitive data such as passwords, tokens, full credit card numbers, or private personal information.
Avoid logging the same exception repeatedly at every layer. Usually, log where the exception is handled or at an application boundary.
Exception Handling in ASP.NET Core APIs
In ASP.NET Core, avoid putting large try/catch blocks in every controller action. Prefer centralized error handling through middleware or exception handlers.
Example concept:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await Results.Problem(
title: "An unexpected error occurred.",
statusCode: StatusCodes.Status500InternalServerError)
.ExecuteAsync(context);
});
});
Controller actions should usually focus on application logic:
[HttpGet("{id:guid}")]
public async Task<ActionResult<CustomerDto>> GetCustomer(Guid id)
{
CustomerDto? customer = await service.GetCustomerAsync(id);
if (customer is null)
{
return NotFound();
}
return Ok(customer);
}
Use explicit responses for expected HTTP outcomes such as 400 Bad Request, 401 Unauthorized, 403 Forbidden, and 404 Not Found. Use centralized exception handling for unexpected failures.
Common Mistakes
Common exception handling mistakes include:
- Catching
Exceptiontoo broadly. - Swallowing exceptions silently.
- Using
throw ex;instead ofthrow;. - Throwing exceptions for normal validation flow.
- Losing inner exception details.
- Logging only
ex.Message. - Forgetting to await a task.
- Using
async voidoutside event handlers. - Throwing from
finally. - Catching cancellation as an error.
- Returning internal exception details to API clients.
- Creating too many custom exception types.
- Catching exceptions at a low level without a real recovery strategy.
Best Practices Summary
Good exception handling usually follows these practices:
- Throw exceptions when a method cannot complete its contract.
- Catch exceptions only when you can recover, translate, add useful context, or handle them at a boundary.
- Catch specific exception types.
- Preserve stack traces with
throw;. - Preserve root causes with inner exceptions.
- Use
usingorawait usingfor disposable resources. - Avoid exceptions for normal control flow.
- Validate public method arguments clearly.
- Treat cancellation differently from failure.
- Log exceptions with context and without sensitive data.
- Centralize API exception handling.
- Keep objects and data consistent when operations fail.