Exception Best Practices in C#
Vaibhav • September 10, 2025
In the previous article, we explored how to throw exceptions in C# - choosing the right type, writing clear
messages, and preserving context with InnerException
. Now that we know how to
throw exceptions, it’s time to step back and look at the bigger picture: how do we design exception handling
that’s clean, maintainable, and resilient? This article covers best practices for working with exceptions in
real-world C# applications.
Exception handling is not just a technical feature - it’s a design discipline. Done well, it makes your code easier to debug, test, and extend. Done poorly, it leads to hidden bugs, confusing behavior, and fragile systems. Let’s explore how to do it well.
Use Exceptions for Exceptional Situations
Exceptions are meant for unexpected, invalid, or unrecoverable conditions - not for routine control flow. If something is a normal part of your program’s logic (like a missing file or a failed search), use return values or status codes instead.
// ❌ Bad: using exceptions for control flow
try
{
int value = int.Parse(input);
}
catch
{
value = 0;
}
// ✅ Good: using TryParse
if (!int.TryParse(input, out int value))
value = 0;
The second approach avoids the overhead of exception handling and makes the intent clearer. Exceptions should be rare and meaningful - not part of your everyday logic.
Catch Only What You Can Handle
Don’t catch exceptions just to make them disappear. Every catch
block should
either:
- Recover from the error
- Log the error for diagnostics
- Wrap and rethrow with additional context
If you can’t do any of these, let the exception propagate. Swallowing exceptions silently hides bugs and makes debugging harder.
// ❌ Bad: swallowing exceptions
try
{
DoSomething();
}
catch
{
// nothing here - error disappears
}
// ✅ Good: logging and rethrowing
try
{
DoSomething();
}
catch (Exception ex)
{
logger.LogError("Error in DoSomething: " + ex.Message);
throw;
}
If you catch an exception, do something useful with it. Otherwise, let it bubble up.
Use Specific Exception Types
Catching Exception
is like saying “I’ll handle anything.” That’s rarely what
you want. Instead, catch specific types that you expect and know how to handle.
// ✅ Good: catching specific exceptions
try
{
ProcessFile("data.txt");
}
catch (FileNotFoundException ex)
{
Console.WriteLine("File not found: " + ex.Message);
}
catch (IOException ex)
{
Console.WriteLine("I/O error: " + ex.Message);
}
This lets you respond appropriately to each error. It also avoids catching bugs you didn’t anticipate.
If you must catch Exception
(e.g., in a top-level
handler), make sure you log it and don’t suppress it silently.
Preserve the Stack Trace
When rethrowing an exception, use throw;
- not throw ex;
. The latter resets the stack trace, making it harder to debug.
// ✅ Good: preserves stack trace
catch (Exception ex)
{
logger.LogError("Error: " + ex.Message);
throw;
}
// ❌ Bad: resets stack trace
catch (Exception ex)
{
throw ex;
}
The stack trace is your breadcrumb trail. Don’t erase it.
Wrap Exceptions with Context
Sometimes you want to catch a low-level exception and throw a higher-level one that makes more sense in your
domain. Use InnerException
to preserve the original error.
try
{
SaveToDatabase();
}
catch (SqlException ex)
{
throw new DataAccessException("Failed to save user data.", ex);
}
This gives you a clean abstraction while still preserving the root cause. It’s especially useful in layered architectures.
Validate Early, Throw Early
Don’t wait until deep inside your code to check for invalid inputs. Validate at the boundaries - constructors, public methods, API endpoints - and throw exceptions immediately if something is wrong.
public User(string username)
{
if (string.IsNullOrWhiteSpace(username))
throw new ArgumentException("Username cannot be empty.", nameof(username));
Username = username;
}
This keeps your objects in a valid state and makes bugs easier to catch.
Document Exception Behavior
If your method throws exceptions, document it. This helps callers understand what to expect and how to handle errors.
/// <summary>
/// Saves the user to the database.
/// </summary>
/// <exception cref="DataAccessException">Thrown if the database operation fails.</exception>
public void SaveUser(User user)
{
...
}
This is especially important in public APIs and shared libraries.
Use Custom Exceptions Thoughtfully
Custom exceptions are great for domain-specific errors - like ValidationException
or PermissionDeniedException
. But don’t create a new type for every minor issue. Use
them when they represent a distinct, actionable condition.
public class ValidationException : Exception
{
public string FieldName { get; }
public ValidationException(string fieldName, string message)
: base(message)
{
FieldName = fieldName;
}
}
This lets you pass structured information to the caller and handle errors more intelligently.
Log Exceptions with Context
When logging exceptions, include all relevant properties - not just the message. A good log entry might look like this:
catch (Exception ex)
{
logger.LogError($"Exception: {ex.GetType().Name}");
logger.LogError($"Message: {ex.Message}");
logger.LogError($"Source: {ex.Source}");
logger.LogError($"TargetSite: {ex.TargetSite}");
logger.LogError($"StackTrace: {ex.StackTrace}");
if (ex.InnerException != null)
{
logger.LogError($"InnerException: {ex.InnerException.Message}");
}
}
This gives you a complete picture of the error and helps you diagnose problems quickly.
Don’t Throw from Finally Blocks
The finally
block is meant for cleanup - not for throwing exceptions. If you
throw from finally
, it can suppress exceptions from the try
block, leading to confusing behavior.
// ❌ Bad: throwing from finally
try
{
DoSomething();
}
finally
{
throw new Exception("Cleanup failed.");
}
Instead, log the error or handle it gracefully.
Use Exception Filters for Precision
C# supports when
clauses to filter exceptions based on runtime conditions. This
lets you catch only the exceptions you care about.
catch (IOException ex) when (ex.Message.Contains("disk full"))
{
Console.WriteLine("Disk is full. Please free up space.");
}
This keeps your error-handling logic clean and focused.
Design for Resilience
Exception handling is part of your system’s resilience strategy. Think about what can go wrong, how to detect it, and how to recover. For example:
try
{
SendEmail();
}
catch (SmtpException ex)
{
logger.LogWarning("Email failed. Will retry later.");
ScheduleRetry();
}
This lets your system degrade gracefully instead of crashing.
Summary
Exception handling is more than just try
and catch
- it’s a design discipline. You’ve learned how to use exceptions for
exceptional situations, catch only what you can handle, preserve stack traces, wrap errors with context, and log
them effectively. You’ve also seen how to use custom exceptions, validate early, and design for resilience.
These best practices will help you write cleaner, safer, and more maintainable C# code. In the next article, we’ll explore Debugging Fundamentals - how to use breakpoints, watch variables, and inspect the call stack to find and fix bugs efficiently.