Custom Exceptions and Filters
Define an exception type for a failure that is specific to your code, and use when filters to catch only the cases you can handle.
catch (MyException ex) when (ex.Code == "retry") { ... }The built-in exception types describe failures the framework knows about. They cannot describe "the card was declined" or "this tenant has no licence". When callers need to react to a failure of your own, give it a type of your own.
Writing one
A custom exception is a class that derives from Exception, with a name ending in Exception. Add properties for the facts a caller would want, so nobody has to read the message text to find the error code. Offer a constructor taking an inner exception when your failure can be caused by another one. Do not derive from ApplicationException: it was an early idea that never earned its place and current guidance is to ignore it.
when filters
A catch clause can carry a condition: catch (PaymentFailedException ex) when (ex.Code == "insufficient_funds"). If the condition is false, this clause does not handle the exception and the search continues outwards, exactly as if the clause were not there. That is better than catching, testing with an if, and rethrowing, because nothing is caught in the first place. You may write several clauses for the same type as long as the one without a filter comes last among them.
What trips people up
Filters are evaluated while the stack is still intact, before any finally block between the throw and the handler runs. So code in a when condition sees state that the cleanup has not yet undone.
Example
int[] amounts = [1500, -5];
foreach (int amount in amounts)
{
try
{
Charge(amount);
}
catch (PaymentFailedException ex) when (ex.Code == "insufficient_funds")
{
Console.WriteLine($"retry later: {ex.Message}");
}
catch (PaymentFailedException ex)
{
// No filter, so this clause takes every other payment failure.
Console.WriteLine($"giving up on {ex.Code}: {ex.Message}");
}
}
void Charge(int amountInCents)
{
if (amountInCents < 0)
{
throw new PaymentFailedException("invalid_amount", "amount cannot be negative");
}
if (amountInCents > 1000)
{
throw new PaymentFailedException("insufficient_funds", "card has no funds left");
}
}
// A failure of your own needs a type of your own, plus the facts about it.
class PaymentFailedException : Exception
{
public PaymentFailedException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}
// Output:
// retry later: card has no funds left
// giving up on invalid_amount: amount cannot be negativeWhen to use it
- Letting a caller tell a declined card apart from a broken payment gateway.
- Handling a 404 from an HTTP call while letting every other status fail.
- Logging every exception that crosses a message-handler boundary without handling any of them.
More examples
The three constructors convention
Library code is expected to offer these three shapes, because tooling and callers assume them. sealed is the default worth choosing: nobody should be deriving from your exception unless you planned for it. The old fourth constructor taking SerializationInfo is obsolete as of .NET 8 and should not be added.
namespace Billing;
public sealed class PaymentFailedException : Exception
{
public PaymentFailedException()
{
}
public PaymentFailedException(string message)
: base(message)
{
}
// Takes the failure that caused this one, so the original is kept.
public PaymentFailedException(string message, Exception innerException)
: base(message, innerException)
{
}
}A filter that only logs
A filter that returns false after doing its work lets you observe every exception passing a point in the code without changing its path. Reach for it in a background worker or a message handler where you want a log line but no handling, and where catch-log-rethrow would be a lie about who handled the failure.
try
{
throw new InvalidOperationException("store is offline");
}
catch (Exception ex) when (Report(ex))
{
// Never reached. Report always returns false, so this clause never
// handles anything and the exception carries on outwards.
}
static bool Report(Exception ex)
{
// Runs before the stack is unwound, so a debugger stopped here still
// shows the frame that threw.
Console.Error.WriteLine($"failed: {ex.Message}");
return false;
}Filtering on data inside the exception
One exception type often covers many situations, and the one you can handle is distinguished by a property rather than by the type. A missing page is an expected answer; a refused connection is not, so it is left to travel on. This example uses async and await, which are taught in the asynchronous stage; the filter works the same way in ordinary code.
using System.Net;
async Task<string> Fetch(HttpClient client, string url)
{
try
{
return await client.GetStringAsync(url);
}
// HttpRequestException.StatusCode is HttpStatusCode?, added in .NET 5.
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return string.Empty;
}
}
Discussion