Back to Blog
C#

Creating Custom Exceptions in C#

c# custom exception: Learn how to define a custom exception in C# that carries precise error context, preserves inner exceptions, and remains serializable across proce...

C#Exception HandlingCustom ExceptionsError Handling.NET
Illustration of a custom exception class in C# with properties and serialization attributes, representing structured error handling.

When a built-in exception type does not describe the failure accurately, you can define a c# custom exception that carries the exact context your callers need. A well-designed custom exception makes error handling explicit and prevents callers from parsing generic messages to guess what went wrong. This article walks through the syntax, the serialization contract, and the design decisions that matter in production code.

Why Define a Custom Exception in C#

The standard library covers common failures: ArgumentNullException, InvalidOperationException, IOException, and many others. But domain-specific failures often do not fit these types. For example, an order processing service might need to signal that an order is already shipped and cannot be cancelled. Using InvalidOperationException with a message string forces callers to match on the message text, which is fragile and easily broken by localization or minor rewording.

A custom exception gives the failure a distinct type. Callers can catch that type directly, and the compiler can help enforce error handling. The exception type itself becomes part of the API contract, just like a method signature or a return type.

The Minimal Custom Exception Class

The simplest custom exception derives from Exception and provides the three standard constructors that the .NET runtime and tooling expect:

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

These constructors mirror the base Exception class. The parameterless constructor is required for serialization and for scenarios where the exception is created without a message. The message-only constructor is the most common entry point. The message-plus-inner-exception constructor preserves the original failure when you are wrapping an exception from a lower layer.

Without these constructors, tools like BinaryFormatter or remote debugging may fail to instantiate your exception type. The .NET runtime also uses the parameterless constructor when rethrowing an exception during deserialization.

Adding Properties to Carry Error Context

A custom exception becomes valuable when it carries structured data that callers can inspect programmatically. For the order example, you might want to expose the order ID and the current status:

public class OrderAlreadyShippedException : Exception { public string OrderId { get; } public string CurrentStatus { get; } public OrderAlreadyShippedException(string orderId, string currentStatus) : base($"Order {orderId} is already shipped and cannot be cancelled. Current status: {currentStatus}.") { OrderId = orderId; CurrentStatus = currentStatus; } public OrderAlreadyShippedException(string orderId, string currentStatus, Exception innerException) : base($"Order {orderId} is already shipped and cannot be cancelled. Current status: {currentStatus}.", innerException) { OrderId = orderId; CurrentStatus = currentStatus; } }

Now a caller can catch this type and read OrderId and CurrentStatus directly, without parsing the message. This is especially useful when the exception is logged or when a user-facing response needs to include the order ID without leaking internal details.

Keep the properties immutable. Exceptions are typically thrown and then inspected; allowing mutation only invites confusion. Use read-only properties initialized in the constructor.

Preserving the Original Error with InnerException

When your code catches a lower-level exception and throws a custom exception in response, you should pass the original exception as the innerException argument. This preserves the full stack trace and any diagnostic information from the root cause.

try { _repository.Save(order); } catch (SqlException ex) { throw new OrderPersistenceException("Failed to save order.", ex); }

The caller can then inspect InnerException to see the underlying SqlException. Without this, the original stack trace is lost, making production debugging significantly harder. The Exception class already provides the InnerException property, so you do not need to define it yourself.

Serialization and the Exception Contract

Exceptions are often serialized when they cross process boundaries, such as in distributed logging, message queues, or Windows Communication Foundation (WCF) calls. The .NET serialization infrastructure expects a specific pattern for custom exceptions. If you add properties, you must serialize them explicitly.

The modern approach uses [Serializable] and the serialization constructor that accepts SerializationInfo and StreamingContext:

[Serializable] public class OrderAlreadyShippedException : Exception { public string OrderId { get; } public string CurrentStatus { get; } public OrderAlreadyShippedException() { } public OrderAlreadyShippedException(string message) : base(message) { } public OrderAlreadyShippedException(string message, Exception innerException) : base(message, innerException) { } public OrderAlreadyShippedException(string orderId, string currentStatus) : base($"Order {orderId} is already shipped.") { OrderId = orderId; CurrentStatus = currentStatus; } protected OrderAlreadyShippedException(SerializationInfo info, StreamingContext context) : base(info, context) { OrderId = info.GetString(nameof(OrderId)); CurrentStatus = info.GetString(nameof(CurrentStatus)); } public override void GetObjectData(SerializationInfo info, StreamingContext context) { base.GetObjectData(info, context); info.AddValue(nameof(OrderId), OrderId); info.AddValue(nameof(CurrentStatus), CurrentStatus); } }

The serialization constructor is protected because it is only called by the deserialization logic. GetObjectData must be overridden to add your custom properties. The [Serializable] attribute is still required in .NET Framework and in .NET Core/5+ for binary serialization compatibility, even though System.Text.Json does not use it.

If you skip this step, your custom properties will be null after deserialization, which can lead to subtle bugs in distributed systems.

When a Custom Exception Is the Wrong Choice

Not every failure warrants a new exception type. Adding a custom exception increases the API surface and forces callers to learn another type. Use a built-in exception when it already matches the failure precisely. For example, ArgumentNullException is appropriate when a null argument is passed, and InvalidOperationException is appropriate when an object is in a state that does not support a method call.

Custom exceptions are most valuable when they represent a domain-level failure that callers need to handle differently from generic errors. If you find yourself catching a custom exception only to log it and rethrow, consider whether the extra type is justified. Similarly, if the exception carries no additional data beyond a message, a built-in type with a descriptive message might be sufficient.

Performance and Maintainability Considerations

Throwing any exception is expensive because the runtime must capture a stack trace and unwind the call stack. A custom exception does not add meaningful overhead beyond that of the base Exception. The cost is dominated by the throw itself, not the type. Therefore, you should not design control flow around exceptions, but you also do not need to worry about the custom type adding a performance penalty.

From a maintainability perspective, keep custom exceptions in a dedicated namespace or folder so they are easy to discover. Document the conditions under which each exception is thrown, and make the property names descriptive. Avoid creating a custom exception for every conceivable failure; instead, group related failures under a single type when callers will handle them the same way.

One common pitfall is throwing a custom exception from a library without also exposing the underlying error. Always include an inner exception when wrapping a lower-level failure. This preserves the diagnostic chain and lets callers decide whether to surface the root cause or a sanitized message.

Another consideration is versioning. Once you ship a custom exception type, changing its properties or constructor signatures can break existing callers. Treat the exception's public surface as a contract. If you must add a property, do so in a backward-compatible way, and consider providing a constructor overload that preserves the original behavior.

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