Back to Blog
C#

C# Custom Exception Class: How to Build One

c# custom exception class: Learn how to create a C# custom exception class, including constructors, serialization, and when to use one instead of built-in exceptions.

C#ExceptionsError Handling.NETException Design
A C# custom exception class hierarchy diagram showing a derived exception class with inner exception and serialization.

When a method needs to signal a failure that a caller can handle distinctly from other failures, a custom exception class gives that failure a name and a type. In C#, the built-in exceptions cover many common cases, but they do not always express the specific condition your code is designed to detect. Creating a C# custom exception class is straightforward: derive from Exception, add constructors that match the base class patterns, and optionally include extra data.

The Basic Shape of a Custom Exception

The simplest custom exception is a class that inherits from Exception and provides the three standard constructors that the base class exposes. These constructors mirror the patterns that callers and the runtime already expect.

public class OrderProcessingException : Exception { public OrderProcessingException() { } public OrderProcessingException(string message) : base(message) { } public OrderProcessingException(string message, Exception innerException) : base(message, innerException) { } }

The parameterless constructor is useful when the exception is created without additional context. The message-only constructor is the most common, and the constructor with an inner exception preserves the original failure when you wrap it. Without these three, code that expects a standard exception shape may not compile or may lose information.

Why Derive from Exception Instead of ApplicationException

Older .NET guidance recommended deriving custom exceptions from ApplicationException. That advice was based on a plan to distinguish exceptions thrown by the application from those thrown by the runtime, but the plan was never fully realized. The runtime itself throws many exceptions that derive directly from Exception, and ApplicationException adds no special behavior. Today, the accepted practice is to derive from Exception directly. This keeps your custom exception aligned with the common base type and avoids the misleading implication that it belongs to a special category.

Serialization and the [Serializable] Attribute

Exception objects often cross boundaries: they are written to logs, passed across process boundaries, or stored in event streams. In .NET Framework, custom exceptions should be marked with [Serializable] and implement the special serialization constructor so that they can be deserialized correctly. In .NET Core and .NET 5+, the serialization support is more limited, and the [Serializable] attribute is not required for the common cases. However, if your code may run in a .NET Framework environment or must support binary serialization, you need to add the attribute and the constructor.

[Serializable] public class OrderProcessingException : Exception { public OrderProcessingException() { } public OrderProcessingException(string message) : base(message) { } public OrderProcessingException(string message, Exception innerException) : base(message, innerException) { } protected OrderProcessingException( System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) : base(info, context) { } }

The protected serialization constructor is called during deserialization. If you add extra properties to your exception, you must override GetObjectData to serialize and deserialize them. Omitting this step means those properties are lost when the exception is serialized.

Constructors and Inner Exceptions

A custom exception often needs to carry more context than a plain message. For example, an order processing exception might include the order ID. You can add a property and a constructor that accepts it.

public class OrderProcessingException : Exception { public string OrderId { get; } public OrderProcessingException(string orderId) : base($"Order {orderId} could not be processed.") { OrderId = orderId; } public OrderProcessingException(string orderId, string message) : base(message) { OrderId = orderId; } public OrderProcessingException(string orderId, string message, Exception innerException) : base(message, innerException) { OrderId = orderId; } }

When you add properties, keep them immutable. Exceptions represent a snapshot of a failure, and allowing the property to change after the exception is created makes it harder to trace the original cause. Also, include the property in the serialization constructor and GetObjectData if you support serialization.

When a Custom Exception Is Worth the Extra Class

A custom exception is useful when callers need to catch a specific failure and treat it differently from other failures. For example, a validation exception might be caught to return a 400 response, while a persistence exception is caught to return a 503. If you only need to pass a message, a built-in exception like InvalidOperationException or ArgumentException may be sufficient. The decision is about the contract you expose to callers.

ConditionUse built-in exceptionUse custom exception
Failure is generic, message is enoughYesNo
Callers need to catch a specific typeNoYes
You need to attach structured dataNoYes
The exception may cross a service boundaryNoYes

A custom exception also makes sense when you want to hide implementation details. For example, a repository might throw a custom DataAccessException instead of leaking a SqlException to the caller. That abstraction keeps the upper layers decoupled from the persistence technology.

Common Mistakes and Maintainability Concerns

One common mistake is to make the custom exception too broad. A single generic MyAppException that wraps everything forces callers to inspect the message to understand the failure, which defeats the purpose of a typed exception. Another mistake is to forget to set the Message property in the constructor. If you rely on the base constructor, the message is set correctly, but if you override Message without calling base, you can produce an empty message.

Another maintainability concern is overusing custom exceptions. Each new exception type adds a class that must be documented and maintained. If you find yourself creating many exceptions that differ only in name, consider whether a single exception with an enum or a property would be simpler. The goal is to make the failure mode explicit without turning the exception hierarchy into a mirror of every possible error.

Performance and Runtime Behavior

Throwing an exception is expensive compared to returning a result. The runtime has to capture the stack trace, allocate the exception object, and unwind the stack. Custom exceptions do not add significant overhead beyond that, but the way you use them can affect performance. For example, throwing an exception in a tight loop can dominate the execution time. If a condition is expected and frequent, consider returning a result object or using a Try pattern instead.

When you do throw a custom exception, the stack trace is captured at the point of the throw. If you rethrow using throw; inside a catch block, the original stack trace is preserved. If you use throw ex;, the stack trace is reset to the new throw point, losing the original location. This applies to custom exceptions exactly as it does to built-in ones.

Custom exceptions that carry extra data also affect memory usage, but the impact is negligible unless you store large objects. Keep the data minimal and avoid referencing large collections in the exception. The exception may be held by logging infrastructure for a long time, and retaining large objects can delay garbage collection.

Serialization Compatibility Across .NET Versions

If your library targets multiple .NET versions, be aware that binary serialization is not supported in .NET Core for all scenarios. In .NET Core, the [Serializable] attribute is still recognized, but the runtime does not automatically serialize all fields. If you need cross-process or cross-machine exception propagation, consider using a data contract or a simple DTO instead of relying on exception serialization. For most applications, exceptions are logged and not serialized, so the simple constructors are enough.

A custom exception class is a small but important part of your API. It defines the contract for failure and gives callers a way to react to specific conditions. By following the standard constructor patterns, adding serialization support when needed, and keeping the exception focused, you can create exceptions that are both useful and maintainable.

c# custom exception class: Practical Usage and Code Examples | RYUSLOG DEV