Designing custom exception types: when they add value, the standard shape, domain data, and alternatives.
Create a custom exception only when callers need to catch it specifically or need extra data that built-in exceptions do not carry. Otherwise use the standard ones: ArgumentException family for bad input, InvalidOperationException for wrong state, NotSupportedException, KeyNotFoundException, TimeoutException. A good custom exception derives from Exception (or a more specific built-in type), not ApplicationException, ends in Exception, provides the three standard constructors (none, message, message plus inner), carries domain properties such as OrderId, and is usually sealed. Do not add the old binary-serialisation constructor: that API is obsolete in modern .NET (SYSLIB0051). For expected business outcomes, a result type is often better than any exception.
I create a custom exception only when callers need to handle that failure differently from existing exceptions, or when it carries domain-specific data that helps handling or diagnosis. Otherwise I use built-in exceptions for programming errors and result types for expected business outcomes. Then I'd walk through how to design one properly in modern .NET.
PaymentDeclinedException with a decline code, ConcurrencyConflictException with entity ID and expected version, RateLimitExceededException with RetryAfter.HttpRequestException from your SDK in ExternalServiceException).| Situation | Use |
|---|---|
| Null argument | ArgumentNullException (ThrowIfNull) |
| Invalid argument value | ArgumentException / ArgumentOutOfRangeException |
| Object in the wrong state for the call | InvalidOperationException |
| Operation not supported by this implementation | NotSupportedException |
| Used after disposal | ObjectDisposedException |
| Cancellation | OperationCanceledException / TaskCanceledException |
| Timeouts | TimeoutException |
| Bad data format | FormatException |
| "Should never happen" branches | UnreachableException (.NET 7) |
These signal bugs or contract violations; callers usually shouldn't catch them, so a custom type adds nothing. Never throw Exception, SystemException, ApplicationException or reserved types like NullReferenceException/IndexOutOfRangeException yourself (CA2201).
For expected business outcomes — validation failures, "not found", "insufficient funds", "order already shipped" — a result type (Result<T, Error>, OneOf, a discriminated record hierarchy, or Results<Ok<T>, NotFound, ValidationProblem> in minimal APIs) is usually better:
A practical split: exceptions for exceptional/unrecoverable or cross-cutting failures, results for expected domain outcomes.
namespace Payments;
public sealed class PaymentDeclinedException : Exception
{
public PaymentDeclinedException() { }
public PaymentDeclinedException(string message) : base(message) { }
public PaymentDeclinedException(string message, Exception innerException) : base(message, innerException) { }
public PaymentDeclinedException(string paymentId, string declineCode, Exception? innerException = null)
: base($"Payment {paymentId} was declined ({declineCode}).", innerException)
{
PaymentId = paymentId;
DeclineCode = declineCode;
}
public string? PaymentId { get; }
public string? DeclineCode { get; }
}
Guidelines:
Exception and describes the problem, not where it came from (PaymentDeclinedException, not PaymentServiceException).Exception directly, or from a meaningful built-in base (InvalidOperationException, IOException) if it's genuinely a specialisation. Don't derive from ApplicationException (obsolete guidance).sealed unless you design a hierarchy on purpose (e.g. a base PaymentException for catch-all handling).Exception.Data sparingly for extra diagnostic context, or better, structured logging.In .NET Framework, exceptions needed [Serializable] and a protected (SerializationInfo, StreamingContext) constructor to cross AppDomains/remoting. In modern .NET:
GetObjectData on Exception are obsolete (SYSLIB0051, .NET 8).ProblemDetails (RFC 9457) in HTTP APIs, to gRPC status codes, or to error messages in queues.A checkout service threw Exception("Card declined") and the API layer used if (ex.Message.Contains("declined")) to return HTTP 402. When a developer changed the wording to "Payment was declined by issuer", every decline started returning HTTP 500 and paging the on-call engineer. We introduced PaymentDeclinedException with a DeclineCode property and mapped it in an IExceptionHandler to a ProblemDetails response (type: .../payment-declined, status 402, decline code as an extension). Later, because declines are an expected outcome, we moved the payment gateway client to return a PaymentResult instead, and kept exceptions for gateway outages and timeouts.
Checks API design judgement around errors rather than syntax.
ApplicationException and obsolete serialisation, mentions result types and central mapping.Exception directly, or uses exceptions for normal control flow.System.Exception contains| Member | Purpose |
|---|---|
Message |
Developer-facing description |
InnerException |
The cause when wrapping |
StackTrace |
Captured during propagation |
HResult |
Integer code (used for COM interop and some I/O errors) |
Data |
IDictionary for extra key/value diagnostics |
Source, TargetSite |
Assembly / method that threw |
HelpLink |
URL to documentation |
GetBaseException() |
Innermost exception in the chain |
Custom exceptions add typed properties rather than putting structured data in Data or the message. Override Message only if you need to compute it lazily from properties.
Good hierarchies are shallow and organised by how callers react, not by which component threw:
Exception
└── ExternalServiceException (catch to degrade gracefully / circuit-break)
├── ExternalServiceUnavailableException (retryable)
└── ExternalServiceRejectedException (not retryable)
Callers can catch the base for general handling or a specific type for specific handling. Deep, component-based hierarchies (OrderModuleException → OrderRepositoryException → ...) don't help anyone decide what to do.
A useful property on a base type: bool IsTransient or TimeSpan? RetryAfter, which a resilience policy can read.
In ASP.NET Core 8+:
public sealed class PaymentExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
{
if (ex is not PaymentDeclinedException declined) return false;
ctx.Response.StatusCode = StatusCodes.Status402PaymentRequired;
return await problemDetails.TryWriteAsync(new()
{
HttpContext = ctx,
ProblemDetails =
{
Type = "https://errors.example.com.au/payment-declined",
Title = "Payment declined",
Status = 402,
Extensions = { ["declineCode"] = declined.DeclineCode }
},
Exception = ex
});
}
}
Register with builder.Services.AddExceptionHandler<PaymentExceptionHandler>(); builder.Services.AddProblemDetails(); and app.UseExceptionHandler();. Domain exception types become a stable contract between layers; the HTTP contract is the type URI and status.
| Aspect | Exceptions | Result types |
|---|---|---|
| Visibility | Hidden in signatures | Explicit |
| Propagation through layers | Automatic | Manual (if (!r.IsSuccess) return r.Error;) |
| Performance on failure | Expensive (stack capture, unwinding) | Cheap |
| Composition with LINQ/async | Natural | Needs helpers (Bind, Map) or libraries |
| Risk | Unhandled → 500/crash | Ignored result → silent bug (mitigate with analysers or [MustUseReturnValue]) |
| Best for | Bugs, infrastructure failures, invariants | Validation, business rules, expected "not found" |
Many teams use both: result types inside the domain/application layer for expected outcomes, exceptions for everything unexpected, and a global handler as the safety net.
[Serializable] + ISerializable constructor needed for remoting, AppDomains and BinaryFormatter.Exception(SerializationInfo, StreamingContext) and GetObjectData marked obsolete (SYSLIB0051).BinaryFormatter removed from the runtime (throws PlatformNotSupportedException).If a library still targets .NET Framework/netstandard, you may keep the serialisation constructor under #if NETFRAMEWORK. For new .NET 10 code, omit it.
logger.LogError(ex, "Payment {PaymentId} declined", id) records type, message, stack and inner exceptions.exception.type, exception.message, exception.stacktrace), so custom exception type names become searchable dimensions — another reason for meaningful names.Data; the Privacy Act and PCI DSS both apply to logs.ToString().IExceptionHandler that maps it to ProblemDetails and call the endpoint.// Base for all domain errors, mapped centrally to 4xx responses
public abstract class DomainException : Exception
{
protected DomainException(string code, string message, Exception? inner = null)
: base(message, inner) => Code = code;
public string Code { get; }
}
public sealed class InsufficientStockException : DomainException
{
public InsufficientStockException(string sku, int requested, int available, Exception? inner = null)
: base("stock.insufficient",
$"Requested {requested} of {sku} but only {available} available.", inner)
{
Sku = sku;
Requested = requested;
Available = available;
}
public string Sku { get; }
public int Requested { get; }
public int Available { get; }
}
// Standard-shape exception for a library (three constructors)
public sealed class ConfigurationLoadException : Exception
{
public ConfigurationLoadException() { }
public ConfigurationLoadException(string message) : base(message) { }
public ConfigurationLoadException(string message, Exception inner) : base(message, inner) { }
}
// Expected outcome: a result instead of an exception
public readonly record struct RegisterResult(bool Succeeded, string? Error)
{
public static RegisterResult Ok() => new(true, null);
public static RegisterResult Fail(string error) => new(false, error);
}
What happens: every module defines its own XyzException hierarchy.
Why it is wrong: adds code without enabling different handling; catch blocks still catch Exception.
Do instead: create custom types only when a caller handles them specifically or needs their data.
What happens: ValidationException, NotFoundException thrown for routine cases and caught in controllers.
Why it is wrong: costly, hidden control flow, noisy error logs and alerts.
Do instead: return result types for expected outcomes; keep exceptions for exceptional cases.
What happens: throw new Exception($"DECLINED|{code}"), and callers parse the message.
Why it is wrong: brittle — any wording change breaks handling.
Do instead: add typed properties to a custom exception.
What happens: throw new Exception(...), throw new ApplicationException(...), throw new NullReferenceException().
Why it is wrong: callers can't catch specifically; reserved types imply runtime bugs (CA2201).
Do instead: use specific built-in types or a meaningful custom type.
What happens: a custom exception only has a constructor taking custom arguments. Why it is wrong: can't be wrapped consistently, and some tools/frameworks expect the standard constructors (CA1032). Do instead: provide parameterless, message, and message + inner constructors.
What happens: wrapping with throw new DataAccessException("Failed").
Why it is wrong: the root cause and its stack trace disappear.
Do instead: always pass innerException when wrapping.
What happens: copying old templates with [Serializable] and SerializationInfo constructors.
Why it is wrong: obsolete APIs (SYSLIB0051) and dead code; BinaryFormatter is gone.
Do instead: omit them; map exceptions to ProblemDetails or messages at boundaries.
What happens: card numbers, emails or SQL text in exception messages that reach logs or API responses. Why it is wrong: privacy and security exposure (Privacy Act, PCI DSS). Do instead: include identifiers, not sensitive values; never return raw exception messages to clients in production.
What happens: OrdersException → OrdersRepositoryException → OrdersSqlRepositoryException.
Why it is wrong: callers care about how to react, not which class threw.
Do instead: organise by reaction (transient vs permanent, not found vs conflict).
What happens: deriving a domain exception from ArgumentException or IOException just to be caught by existing handlers.
Why it is wrong: semantically misleading; handlers may treat it incorrectly (e.g. retry logic for I/O).
Do instead: derive from Exception or a base that truly matches the meaning.
Seniors design exceptions as part of the API contract and connect them to how errors are surfaced to clients.
| Error kind | Mechanism | Surface |
|---|---|---|
| Programmer error (bad argument) | built-in Argument*Exception |
500, fix the bug |
| Invalid state | InvalidOperationException |
500 or 409 |
| Domain rule violated | custom DomainException subtype |
4xx ProblemDetails |
| Expected outcome | result type / Try pattern | normal response |
| Infrastructure failure | let it bubble, wrap at boundary | 500/503, retry policy |
The senior point is consistency: one base domain exception with a code, one central mapper (IExceptionHandler) to ProblemDetails, and a rule about when to use results instead of exceptions, so every team handles errors the same way.
In ASP.NET Core, register AddProblemDetails(), one or more IExceptionHandler implementations (.NET 8+) with AddExceptionHandler<T>(), and app.UseExceptionHandler() early in the pipeline. Handlers log once and map known exceptions to the right status code and an RFC 9457 ProblemDetails body, and never leak stack traces in production. For the process as a whole, AppDomain.CurrentDomain.UnhandledException is a last-chance notification: you can log and flush, but the process still terminates. TaskScheduler.UnobservedTaskException fires when a faulted task that nobody awaited is garbage-collected; it does not crash the process by default. In a BackgroundService, an unhandled exception in ExecuteAsync stops the host by default (.NET 6+). async void exceptions go to the synchronisation context and usually crash the process, so avoid async void except for event handlers.
A try block costs almost nothing when no exception is thrown; .NET uses table-based handling, so there is no work on the happy path (though a method containing try/catch may get fewer JIT optimisations such as inlining). Throwing is expensive: allocating the exception, capturing the stack trace, and the two-pass search and unwind cost microseconds, versus nanoseconds for a normal return, and more with deep stacks. .NET 9 replaced the C++-based exception handling with a managed implementation (from NativeAOT), making throw and catch roughly 2 to 4 times faster, but it is still far slower than returning a value. So keep exceptions for exceptional cases, and on hot paths with expected failures use Try methods (int.TryParse, TryGetValue), result types, and throw helpers that keep the throwing code out of line. Measure exception rates with dotnet-counters before optimising.
How to tell it (STAR)
**Situation:** During [a sale event] at [company], our checkout showed a generic error and our on-call phone kept ringing, yet most failures were simply items going out of stock. **Task:** I wanted customers to see a useful message and on-call to stop being paged for normal business outcomes. **Action:** The stock check threw a plain `Exception`, so the global handler returned 500. I introduced a small domain exception hierarchy with error codes, created `InsufficientStockException` with the SKU and quantities, and mapped domain exceptions to 409 responses using `ProblemDetails`. I also enabled CA2201 to stop new code throwing `System.Exception`. **Result:** 500s during the next sale dropped by [about X%], customers saw 'only N left', support tickets for checkout fell, and on-call was paged only for real failures.
Last updated 30 Oct 2026.