AlgoMaster Logo

Custom Exceptions

Last Updated: May 22, 2026

High Priority
16 min read

The base class library ships with a few hundred exception types, and most of the failures your code runs into already have a perfectly good one waiting (ArgumentNullException, InvalidOperationException, FileNotFoundException, and so on). A custom exception is appropriate when none of those describe the failure accurately, or when the caller needs to branch on the specific reason the operation went wrong. This chapter covers how to design one that fits cleanly into the rest of the framework: which base class to derive from, which constructors to write, what properties to add, and which patterns from older .NET to leave behind.

Why Create a Custom Exception

Almost every codebase eventually grows a few exception types of its own. The reason is that the built-in exceptions describe generic failures, and a generic failure isn't always enough to let the caller make a good decision.

Consider an e-commerce checkout. The order can fail because the item is out of stock, because the coupon the customer entered has expired, or because the payment provider declined the card. All three are real failures, and from the framework's point of view they could all be reported as InvalidOperationException. But the calling code needs to do different things in each case. Out of stock should suggest a similar product. Expired coupon should ask the customer to try a different code. Declined payment should redirect to a "try another card" page. If everything is InvalidOperationException, the only way to branch is to read the message string, which is fragile, locale-dependent, and not something a static analyzer can check.

A custom exception type fixes this with one stroke. Each failure mode becomes its own type. The caller writes one catch per type, with no string matching, no enum checks, no hidden coupling to the wording of the message.

There are three signals working here at once. The type tells the caller which failure happened. The message is a human-readable description for logs. The property (ProductName, CouponCode, DeclineCode) carries structured data the caller can use programmatically without parsing the message.

A reasonable rule of thumb: if the calling code would ever want to handle this failure differently from a sibling failure, the two failures deserve different exception types. If every caller of your method would catch both the same way and log them identically, a single existing exception with a good message is plenty.

The other reason to write a custom exception is to mark the boundary of your subsystem. A checkout module that throws nothing but CheckoutException (and its subclasses) is easy to wrap in a catch-all when the rest of the application wants to retry or surface a friendly error. Code in the controller doesn't need to know every leaf type; it knows "anything from checkout is a CheckoutException."

Don't write a custom exception when the BCL already has one that fits:

FailureUse the existing exception
A parameter is null and shouldn't beArgumentNullException
A parameter is outside its allowed rangeArgumentOutOfRangeException
A parameter has the wrong format or valueArgumentException
The object is in a state that doesn't allow the operationInvalidOperationException
The feature isn't implemented yetNotImplementedException
The operation isn't supported on this typeNotSupportedException
A key wasn't found in a dictionaryKeyNotFoundException
The collection was modified during iterationInvalidOperationException

Reaching for a custom type when one of these would do adds noise without adding meaning. Save the custom types for domain-specific failures the framework couldn't have anticipated.

The Three Canonical Constructors

When you write a custom exception, derive from System.Exception directly. There used to be advice to derive from ApplicationException instead, but Microsoft retracted that years ago, and the BCL itself doesn't follow it. ApplicationException adds no value, and code that catches it instead of Exception is rare to the point of being a curiosity. Derive from Exception and move on.

The convention for the constructors comes from how the BCL exceptions are shaped. Every well-behaved exception type provides three constructors:

  1. A parameterless constructor, useful for deserialization and frameworks that need to construct an instance without arguments.
  2. A constructor that takes a string message, for the common case where the caller wants to attach a description.
  3. A constructor that takes a string message and an Exception innerException, for wrapping a lower-level cause without losing it.

Here's the minimum you should write:

The parameterless new CheckoutException() falls back to a default message produced by Exception itself ("Exception of type 'X' was thrown."), which is why it looks the way it does. That default isn't useful in production logs, so most code calls the second or third constructor. The parameterless form mostly exists to be source-compatible with frameworks that expect every exception type to have one.

A picture of how the message and inner exception flow into the base class makes the wiring concrete:

The custom constructor's only job is to forward its arguments to the matching base(...) constructor on System.Exception. The base class stores the message and inner exception in its own fields and surfaces them through the Message and InnerException properties. Your subclass doesn't manage that state itself; it just routes the arguments where they need to go.

Skipping a constructor breaks consumers who rely on the convention. Reflection-based code, mocking frameworks, serialization, and certain logging libraries assume an exception has at least the (string) and (string, Exception) constructors. If you only write the parameterless one, you're going to confuse code that calls Activator.CreateInstance(typeof(MyException), message) and find out later through a bug report.

If you want to enforce that callers provide a message and never call the parameterless form, drop the parameterless constructor. There's no rule that forces all three, only a convention. For a domain exception where the message is required, the two-constructor form is fine:

The other reason to omit constructors is when your exception requires a custom property (covered next), and you don't want to expose a constructor path that leaves that property uninitialized.

Constructing an exception captures the stack trace, which is one of the more expensive operations in .NET. The cost is paid when the throw runs, not when the constructor returns. Exceptions are not free. The construction itself is cheap; the throw is what triggers stack capture.

Adding Custom Properties

The interesting part of a custom exception is the data it carries beyond the message. A bare Message string is fine for a log file, but if the caller wants to put the failing product id into a retry queue, or include the declined card's brand in an analytics event, parsing the message is the wrong tool. A typed property is the right one.

The pattern is: declare a public get-only property, set it in the constructor, and forward enough information to the base Exception for the message to be human-readable.

The first line of output is the auto-printed ToString() summary that the runtime produces when you write the exception directly. The next two are explicit prints. The property access matters: ex.ProductId returns the integer 4711 without anyone having to parse the message.

The properties are get-only. Once an exception has been thrown, nothing should change about it. A caller higher in the stack might log it, decide whether to rethrow, attach more context, and pass it on. Mutability would let one of those steps overwrite data another step depends on. Exceptions are conceptually immutable values that describe what went wrong, and that's how their state should be modeled.

Get-only auto-properties (the public int ProductId { get; } syntax) are the standard way to express that. The property has no setter, so the only place its value can be assigned is inside a constructor of the declaring class. That's the contract you want.

For a payment-related failure, the structured data might look different:

Three properties capture the three things a retry policy actually needs: the decline code so it can decide whether the card might work later, the brand for analytics, and the amount for the receipt. The message is for the log; the properties are for the code.

A common mistake is to add a property and then forget to mention it in the message. The class compiles fine, but the log entry is missing context. When you read the exception later in a traceback, you see "Payment declined." with no hint of which card or what amount, and you have to dig through correlated logs to reconstruct the situation. Always make sure the Message includes the same data the properties carry, even if it duplicates them. The redundancy is the point: the properties are for programmatic access; the message is for humans reading logs.

Extra properties cost almost nothing. They're just fields, and an exception that already exists is going to allocate one object whether it has zero or ten properties. The cost of an exception is dominated by the stack trace capture at throw time, not by the size of the exception object.

If a property is reference-typed (a string, a list, a domain object), prefer immutability all the way down. A IReadOnlyList<int> reads better than a mutable List<int> because the consumer can't accidentally mutate the data the exception is carrying.

The constructor accepts the list, the property exposes it, and IReadOnlyList<T> signals to the caller that the list isn't meant to be modified. If you wanted to be really strict, you could copy the input into a new list inside the constructor, so even the original caller can't mutate the exception's data after the fact. That's worth doing if the exception will cross subsystem boundaries.

Designing a Hierarchy

Once a project has more than a handful of custom exceptions, putting them in a flat list starts to hurt. You end up either writing one catch per leaf type (verbose) or catching Exception (too broad). A shallow hierarchy with a single root for each domain solves this.

Pick a base class for each subsystem, and have every domain-specific exception in that subsystem derive from it. Catchers can then choose: handle a specific leaf, or handle anything from the subsystem.

A single catch (CheckoutException ex) clause swallows every leaf type. Code higher in the stack that doesn't care about the specific failure (a global error handler in an ASP.NET pipeline, for example) only needs to know about the root type. Code that wants to react to a specific failure can write a more specific catch lower in the chain.

The class structure looks like this:

Exception is the BCL root, CheckoutException is the abstract domain root for the checkout subsystem, and the three concrete leaves are sealed so no one extends them further. Each leaf carries its own typed properties. The hierarchy is one level deep on purpose. Deeper trees don't usually pay for themselves; the cost of understanding the structure goes up faster than the benefit.

The abstract root deserves a closer look. Marking CheckoutException as abstract says two things at once: "no one will ever throw this base type directly," and "every checkout failure has a more specific reason." The compiler enforces the first point by refusing new CheckoutException(...) at the call site. The protected constructors are visible only to subclasses, which is the only place they're needed anyway.

Trying to instantiate the abstract base is a compile error (CS0144: "Cannot create an instance of the abstract type or interface 'CheckoutException'"). That's a feature, not a limitation. If you find yourself wanting to do this, use one of the existing leaves or add a new one named after the actual failure.

A non-abstract root is also legitimate when the root type itself represents a meaningful "I don't know more than this" case. Some teams prefer a non-abstract root with a generic message for failures that don't fit any other category, and abstract roots only when every failure must be classified. Either choice is defensible. The abstract form is cleaner when the leaves cover every case, which is usually true once the subsystem has matured.

Don't try to express categories with inheritance. A two-level tree like "CheckoutException -> PaymentException -> PaymentDeclinedException -> InsufficientFundsException" sounds organized but adds little. Catchers don't get a fourth useful level of granularity; they get four levels of types to remember. Keep the tree flat. If a leaf needs more detail, add a property, don't add a subclass.

Naming and sealed

Two conventions show up in every codebase that handles custom exceptions well, and both are worth following from the start.

The first is naming. End every exception type name with the word Exception. OutOfStockException, not OutOfStock. InvalidCouponException, not BadCoupon or CouponError. The suffix signals to anyone reading the code that they're looking at a type they can throw and catch, which is exactly the kind of thing you want screaming from a class name. The framework follows this convention strictly (ArgumentNullException, FileNotFoundException, OperationCanceledException), and so should you. Static analyzers like the Roslyn analyzers ship a rule (CA1710) that warns when an exception type doesn't end in Exception.

The second is sealed. If a class isn't designed for inheritance, mark it sealed. Most leaf exceptions fit this description: there's no reason for anyone else to derive from OutOfStockException, because the alternative is just adding another leaf to the hierarchy you already control. Sealing is the explicit form of that decision.

Sealing has three benefits. The JIT can devirtualize calls on a sealed type, which is a tiny performance win that doesn't matter for exceptions but is a free benefit. It documents intent: anyone reading the code sees the sealed keyword and knows the class is not designed to be extended. And it prevents drift: a year from now, no one in another project can subclass your exception and start throwing it with weird semantics that break the catchers you control.

The abstract root in the hierarchy is the opposite. The whole point of CheckoutException is to be extended. Don't seal it. Mark it abstract so it can't be instantiated directly, but leave it open for subclassing.

If a leaf type ever needs to be extended (which is rare, but it happens), unsealing later is a non-breaking change. Sealing later is breaking. Starting with sealed and unsealing if necessary is the right default.

Sealing has no measurable runtime cost. The mild JIT benefit from devirtualization is positive, not negative. The cost is on the design side, not the runtime side: a sealed class is a deliberate "no, you can't extend this" decision.

Serialization in Modern .NET

Older .NET guidance for custom exceptions included a fourth constructor: protected MyException(SerializationInfo info, StreamingContext context) plus an override of GetObjectData. That pattern existed because exceptions, like other serializable types, used to be passed between AppDomains and across BinaryFormatter boundaries, and the runtime needed a way to rehydrate the custom state when an exception crossed those boundaries.

That pattern is no longer recommended in .NET 8 and later. Binary serialization through BinaryFormatter was a long-running source of remote code execution vulnerabilities, and the .NET team has deprecated the whole machinery. The relevant ISerializable infrastructure on Exception is marked obsolete via warnings SYSLIB0050 and SYSLIB0051, and BinaryFormatter itself has been removed from the platform. If you write the old serialization constructor today, you'll get build warnings telling you to stop.

The compile-time warnings on those two members are the framework telling you the underlying mechanism is being phased out. You can leave them in place if the project has a hard dependency on legacy serialization, but new code should not add them.

The modern replacement is simpler. For cross-process transport (logging, message buses, distributed tracing), serialize a regular DTO using JSON, not the exception type itself. The catch site builds a DTO from the exception, the receiver reads the DTO and either reconstructs the exception or just inspects the data.

The DTO has init-only properties, so it's immutable after construction, just like the exception. JSON gives you a stable wire format that works across language boundaries (the JavaScript front end can read it just as easily as the C# service can), works with any HTTP transport, and doesn't open the runtime up to deserialization gadget attacks. There's no separate constructor to write, no [Serializable] attribute, no GetObjectData override. The exception class stays small.

For in-process exception handling (the only case that actually matters most of the time), there's no serialization step at all. The exception object travels up the stack frame by frame as a plain managed object. No copying, no serialization, no DTO. You only need a transport format when the exception leaves the process.

The short version of the rule: don't write the serialization constructor in new code. If you inherit a project that has one, suppress the warning if the constructor is still in use, or delete it if nothing references it. For anything that needs to cross a process boundary, use a JSON DTO instead.

What Not to Override

A custom exception is a normal class, which means you have the same toolbox available that you'd have on any class: properties, constructors, methods, operator overloads, ToString, Equals, GetHashCode. Just because you can override these doesn't mean you should. Most of them are wrong for exceptions.

Equals and GetHashCode are the clearest case. Exceptions are not values that you compare for logical equivalence. Two OutOfStockException instances with the same ProductId aren't "the same exception"; they're two separate failure events that happened to share data. Comparing them for equality with == or putting them in a HashSet<Exception> doesn't model anything meaningful, and the default reference-equality behavior is exactly what you want. Leave Equals and GetHashCode alone.

That override looks reasonable but causes problems. Logging frameworks that key on exception identity see two different exceptions as one. Tests that assert two distinct failures occurred can't distinguish them. The "value" interpretation of equality doesn't fit exceptions.

ToString is the override that's borderline. The default Exception.ToString() produces a multi-line summary with the type name, message, inner exception chain, and stack trace, which is what logging frameworks expect. Overriding it usually breaks logging in subtle ways. If you want to add a structured representation, expose it as a separate method (like ToFaultJson()) or as the DTO approach from the previous section, not as ToString. Leave the framework's ToString in place.

The same logic applies to operator overloads (==, !=, <, etc.). They make no sense for exceptions, so don't define them.

What you should override or add:

  • Custom properties. This is the main reason to write the class at all.
  • Helpful constructors. The canonical three, plus any constructors that match how your subsystem produces the failure.

What you shouldn't override:

  • Equals and GetHashCode (reference equality is correct).
  • ToString (the default format is what logs expect).
  • Operator overloads (no semantic meaning).
  • MemberwiseClone or other cloning machinery (exceptions are immutable values; you don't need copies).

Constructors deserve one more note. Make sure every constructor sets every custom property. If you provide a parameterless constructor on a class that has a ProductId property, the constructor needs to either set ProductId to a sentinel value (which is usually a bad sign) or leave the property at its default. Both options are worse than just not having the parameterless constructor in the first place. If a property is part of the exception's identity, demand it in the constructor signature.

Adding more code to an exception class doesn't slow anything down at throw time. The expensive step is stack trace capture inside the runtime, which happens regardless of how many properties or methods the class defines. The reason to keep the class small is clarity, not performance.

Looking Ahead

Custom exceptions exist to give the caller something they can branch on, and to give your subsystem a clean boundary. Derive from Exception, write the canonical constructors, add immutable properties for the structured data that matters, name the class with the Exception suffix, sealed the leaves, and skip the legacy serialization machinery. That's the whole shape of a well-designed custom exception in modern .NET.

The one piece left to cover is the inner exception, which we've touched on with the third constructor but haven't really explored. When a low-level failure causes a high-level failure (the database driver throws, your repository wraps it as a RepositoryException, the controller wraps that as a CheckoutException), you end up with a chain of exceptions where each layer knows about the layer below it. The next chapter walks that chain: how to build it, how to walk it without recursion, how AggregateException represents multiple failures at once, and how to find the root cause when you only have the outermost exception in hand.