An exception is how a method reports that it could not do what its name promised, so every throw, catch and finally is a statement about what the surrounding code promises too. These are the shapes that keep the promise honest: the right type for the condition, the smallest catch that can actually recover, and the stack trace intact when the failure reaches the log.
Throw for errors, not for outcomes the caller expects
An exception reports that a method could not do what it promised, which makes it the wrong signal for a result the caller already has to handle, such as a key that is missing or a string that will not parse. Throwing for those makes ordinary execution pay for a stack unwind and buries the real failures among the routine ones.
Preferred
if (!_users.TryGetValue(id, out var user))
{
return null;
}Avoid
try { return _users[id]; }
catch (KeyNotFoundException) { return null; }Corpus: Create and throw exceptions · Best practices for exceptions
Reach for the Try member when failure is routine
The framework pairs a throwing member with a Try member when the failure is an ordinary result: int.TryParse next to int.Parse, Dictionary<TKey,TValue>.TryGetValue next to the indexer. The Try member reports the failure through its return value and hands back the result through an out parameter, so a caller that expects misses pays nothing for them.
Preferred
if (int.TryParse(text, out int size))
{
Resize(size);
}Avoid
try { Resize(int.Parse(text)); }
catch (FormatException) { Resize(DefaultSize); }Corpus: Best practices for exceptions
Check arguments with the throw helpersCA1510
Argument guards open most public methods, and the framework ships static throw helpers for them: ArgumentNullException.ThrowIfNull, ArgumentException.ThrowIfNullOrEmpty, ArgumentException.ThrowIfNullOrWhiteSpace, ArgumentOutOfRangeException.ThrowIfNegative and its neighbours, ObjectDisposedException.ThrowIf. Each fills in the argument name itself, so the check stays one short line instead of a branch that constructs an exception.
Preferred
public void Add(string name, int count)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentOutOfRangeException.ThrowIfNegative(count);
_items.Add(new Item(name, count));
}Avoid
if (name is null)
throw new ArgumentNullException(nameof(name));
if (count < 0)
throw new ArgumentOutOfRangeException(nameof(count));Corpus: CA1510: Use ArgumentNullException throw helper · Best practices for exceptions
Throw the most specific type there isCA2201
Exception, SystemException and ApplicationException tell a handler nothing it can act on, and the runtime reserves NullReferenceException, IndexOutOfRangeException, OutOfMemoryException and the interop exceptions for itself. Reach for the type that names the condition: ArgumentNullException for a null argument, ArgumentOutOfRangeException for a value outside its range, InvalidOperationException for a call the current state does not allow, ObjectDisposedException after disposal, NotSupportedException for an operation the type never supports, FormatException for a malformed string.
Preferred
throw new InvalidOperationException("The connection is already open.");Avoid
throw new Exception("Something went wrong.");Corpus: CA2201: Do not raise reserved exception types · Best practices for exceptions
A bad argument is not the same as a bad state
Both exceptions refuse a call, and the difference is what the caller has to change. The ArgumentException family points at a parameter: the value is null, out of range, or malformed, and the exception carries the parameter name. InvalidOperationException says the object is in a state where the call does not apply, whatever the arguments were, which is why it takes no parameter name.
Preferred
public void SetTitle(string title)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
_title = title;
}Avoid
public void SetTitle(string title)
{
if (_title is not null)
throw new ArgumentException("A title is already set.");
_title = title;
}Corpus: CA2208: Instantiate argument exceptions correctly · Create and throw exceptions
Do not catch what the code cannot handleCA1031
A bare catch, catch (Exception) or catch (SystemException) claims the handler knows what to do with every failure, including the runtime errors that point at a bug in the code above it. A catch belongs around the specific type that will be recovered from, so anything else keeps travelling to code that can report it, and a crash with a stack trace beats an app that carries on in an unknown state. Where a general catch is unavoidable, it rethrows as its last statement.
Preferred
catch (FileNotFoundException ex)
{
Console.Error.WriteLine(ex.Message);
return Default;
}Avoid
catch (Exception) { }Corpus: CA1031: Do not catch general exception types · Exception Handling · Best practices for exceptions
Filter with `when` instead of catching and rethrowing
A when clause is evaluated before the stack unwinds, so the condition runs with the exception still at the point it was raised, and a filter that returns false leaves the stack trace untouched. One catch clause per condition replaces a handler that inspects the exception and calls throw to pass it on, and a filter that matches nothing costs no unwind.
Preferred
catch (ArgumentException ex) when (ex.ParamName == nameof(count))
{
count = DefaultCount;
}Avoid
catch (ArgumentException ex)
{
if (ex.ParamName != nameof(count))
throw;
count = DefaultCount;
}Corpus: Exception-handling statements - throw and try, catch, finally · Use user-filtered exception handlers
Rethrow with `throw;`CA2200
throw; rethrows the exception being handled and keeps the stack trace recorded where it was raised, which is the frame a reader needs first. throw ex; restarts that trace at the current method, so every frame between the failure and the handler disappears. Outside a catch block, an exception is rethrown by capturing it with ExceptionDispatchInfo.Capture and calling Throw on the captured instance.
Preferred
catch (IOException)
{
Cleanup();
throw;
}Avoid
catch (IOException ex)
{
Cleanup();
throw ex;
}Corpus: CA2200: Rethrow to preserve stack details · Best practices for exceptions
Clean up in `finally`, and keep exceptions out of itCA2219
finally runs whether the block returned, jumped out, or threw, which is what makes it the place for cleanup that must happen. For a type that implements IDisposable or IAsyncDisposable the compiler writes that try-finally for you, so using is the shorter form. A throw from a finally, filter or fault clause is the exception that cannot be handled where it happens: in a finally it replaces the one already in flight, and in a filter the runtime discards it and treats the filter as false.
Preferred
using var stream = File.OpenWrite(path);
stream.WriteByte(value);Avoid
finally
{
_connection.Close();
}Corpus: CA2219: Do not raise exceptions in exception clauses · How to execute cleanup code using finally · Exception-handling statements - throw and try, catch, finally
`await` unwraps the fault, `Wait` hands back the wrapper
A faulted task stores its exception until code observes it. await throws the stored exception unwrapped, so the AggregateException that Task.WhenAll produces cannot be caught around an await and the handler names the underlying type instead. Task.Wait() and Task.Result rethrow the AggregateException itself, and InnerExceptions, Handle or Flatten are how the individual failures are read. Argument checks written before the first await still throw synchronously, which is where a caller sees them.
Preferred
try { await Task.WhenAll(saves); }
catch (IOException ex) { Console.Error.WriteLine(ex.Message); }Avoid
try { await Task.WhenAll(saves); }
catch (AggregateException ex) { Report(ex.InnerExceptions); }Corpus: Exception handling (Task Parallel Library) · Best practices for exceptions
Write a custom exception only when no built-in type fitsCA1064
A new exception type earns its place when callers must distinguish the failure by type rather than by message. Derive from Exception, end the name with Exception, keep the type public, and provide the three constructors: parameterless, message, and message with an inner exception. Leave out the serialization constructor: rule CA2229 asked for one and was removed in .NET 8, because the binary serialization APIs it served are obsolete.
Preferred
public sealed class TransferFailedException : Exception
{
public TransferFailedException() { }
public TransferFailedException(string message) : base(message) { }
public TransferFailedException(string message, Exception inner) : base(message, inner) { }
}Avoid
class TransferFailed : ApplicationException
{
}Corpus: CA1064: Exceptions should be public · How to create user-defined exceptions · CA2229: Implement serialization constructors
Mark a method that always throws with `[DoesNotReturn]`
The nullable analysis stops at a call to a method marked [DoesNotReturn], which is how a guard method that throws tells the compiler that nothing continues past it. [DoesNotReturnIf] does the same for a method that throws only when a bool argument has a given value, so the guard does not leave a null state for the caller to appease.
Preferred
[DoesNotReturn]
static void Fail(string message) => throw new InvalidOperationException(message);Avoid
[DoesNotReturn]
static void Warn(string message) => Console.Error.WriteLine(message);Corpus: Attributes interpreted by the compiler: Nullable static analysis
Document the exceptions a member throws
The <exception> tag names one exception type a member can throw and describes the condition behind it, and the compiler checks that the cref resolves to a real type. That list is part of the contract callers code against: they choose what to catch from it, and a derived class or a later version keeps throwing the documented types.
Preferred
/// <exception cref="FileNotFoundException">The path does not exist.</exception>
public byte[] ReadAll(string path) => File.ReadAllBytes(path);Avoid
/// <summary>Reads the file.</summary>
public byte[] ReadAll(string path) => File.ReadAllBytes(path);Corpus: Recommended XML documentation tags · Create and throw exceptions
Log the exception once, where it is handled
A failure that is written to the log and rethrown appears again at every layer it passes, and the entry from the layer that finally acts on it is buried under the ones that only passed it along. Write the entry where the code can do something about the failure. For a record that does not claim the exception, a when filter that logs and returns false writes it while the original stack travels on to a handler.
Preferred
catch (IOException ex)
{
Console.Error.WriteLine(ex);
return Default;
}Avoid
catch (IOException ex)
{
Console.Error.WriteLine(ex);
throw;
}