C# Custom Exceptions
Custom exceptions are exception classes you create for failures that have special meaning in your application. They let callers catch a precise problem, inspect structured details, and keep error-handling code clearer than checking message text. A good custom exception describes a real category of failure, not just a different sentence.
Overview: How Custom Exceptions Work
Every C# exception is an object whose type derives from System.Exception. Built-in exceptions already cover many common problems: ArgumentNullException, ArgumentOutOfRangeException, InvalidOperationException, FileNotFoundException, FormatException, and many more. A custom exception is useful when those built-in types are too general for code that needs to make a domain-specific decision.
For example, a checkout system might throw OutOfStockException when an item cannot be purchased. A course platform might throw EnrollmentClosedException when registration is no longer allowed. Those failures are not just invalid arguments; they are business rules with names, data, and handling paths. Callers can catch exactly that exception type and show a useful response without guessing from a message string.
Under the hood, a custom exception is a normal class. It participates in inheritance just like any other type. When you write throw new EnrollmentClosedException(...), the CLR creates the object, records stack trace information as it travels, and searches the call stack for a compatible catch. A catch (Exception) can catch it because every exception inherits from Exception. A catch (EnrollmentClosedException) is more specific and should appear before broader catches.
The most important design choice is whether a new type helps the caller. If nobody will catch it separately and it has no useful extra data, a built-in exception with a clear message is often better. If callers need to distinguish the case or read properties such as an order id, balance, course code, or validation limit, a custom exception is appropriate.
Modern C# custom exceptions usually provide the standard constructors: a parameterless constructor, a message constructor, and a message plus inner-exception constructor. Many also add a domain-specific constructor that fills immutable properties. Older .NET code sometimes added binary serialization support, but that pattern is obsolete for most new .NET 8 applications.
Syntax
using System;
public class MyDomainException : Exception
{
public MyDomainException()
{
}
public MyDomainException(string message)
: base(message)
{
}
public MyDomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}
class Program
{
static void Main()
{
}
}
| Part | Purpose |
|---|---|
: Exception |
Makes the class an exception type that can be thrown and caught by exception handlers. |
Exception suffix |
Follows .NET naming convention and makes the type’s purpose obvious. |
message |
Human-readable explanation for logs, debugging, and user-facing translation layers. |
innerException |
Preserves the lower-level cause when you wrap one exception in another. |
| Custom properties | Carry structured data so handlers do not parse Message. |
Examples
Example 1: A Simple Domain Exception
using System;
public class InvalidOrderQuantityException : Exception
{
public InvalidOrderQuantityException(int quantity)
: base($"Order quantity must be between 1 and 10. Received: {quantity}.")
{
Quantity = quantity;
}
public int Quantity { get; }
}
class Program
{
static void ValidateQuantity(int quantity)
{
if (quantity < 1 || quantity > 10)
{
throw new InvalidOrderQuantityException(quantity);
}
}
static void Main()
{
try
{
ValidateQuantity(25);
Console.WriteLine("Quantity accepted.");
}
catch (InvalidOrderQuantityException ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine($"Invalid value: {ex.Quantity}");
}
}
}
Output:
Order quantity must be between 1 and 10. Received: 25.
Invalid value: 25
This exception is more useful than a plain Exception because the handler can catch the exact rule failure and read the invalid Quantity property. The message is for humans; the property is for program logic.
Example 2: Include Standard Constructors
using System;
public class EnrollmentClosedException : Exception
{
public EnrollmentClosedException()
{
}
public EnrollmentClosedException(string message)
: base(message)
{
}
public EnrollmentClosedException(string message, Exception innerException)
: base(message, innerException)
{
}
}
class Program
{
static void Main()
{
try
{
throw new EnrollmentClosedException("Enrollment is closed for this course.");
}
catch (EnrollmentClosedException ex)
{
Console.WriteLine(ex.GetType().Name);
Console.WriteLine(ex.Message);
}
}
}
Output:
EnrollmentClosedException
Enrollment is closed for this course.
The standard constructors make the type flexible. Some callers may only need the type, some need a custom message, and some need to wrap a lower-level exception. Even if your first version only uses one constructor, adding the common set keeps the exception familiar to other .NET developers.
Example 3: Wrap A Lower-Level Failure With InnerException
using System;
public class CourseImportException : Exception
{
public CourseImportException(string courseCode, int lineNumber, string message, Exception innerException)
: base(message, innerException)
{
CourseCode = courseCode;
LineNumber = lineNumber;
}
public string CourseCode { get; }
public int LineNumber { get; }
}
class Program
{
static int ParseSeatCount(string courseCode, int lineNumber, string text)
{
try
{
return int.Parse(text);
}
catch (FormatException ex)
{
throw new CourseImportException(
courseCode,
lineNumber,
$"Invalid seat count on line {lineNumber} for {courseCode}.",
ex);
}
}
static void Main()
{
try
{
int seats = ParseSeatCount("CSHARP101", 7, "many");
Console.WriteLine($"Seats: {seats}");
}
catch (CourseImportException ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine($"Course: {ex.CourseCode}");
Console.WriteLine($"Cause: {ex.InnerException?.GetType().Name}");
}
}
}
Output:
Invalid seat count on line 7 for CSHARP101.
Course: CSHARP101
Cause: FormatException
This example translates a technical parsing failure into a course-import failure. The original FormatException is not lost; it is stored in InnerException. That gives high-level code a domain-specific exception while preserving the low-level debugging cause.
How It Works Step By Step
- Your custom exception class is compiled like any other class, with metadata saying it derives from
Exception. - Code creates the exception object with
new, passing any message, inner exception, or domain values to the constructor. - The constructor calls a base
Exceptionconstructor, which stores the message and inner exception. - When
throwruns, normal execution stops and the CLR begins stack unwinding. - The CLR compares the thrown object’s runtime type with each nearby
catchtype. Exact matches and base-class matches are compatible. - If a matching handler is found, the handler receives the same exception object, including its custom properties.
- If a custom exception wraps another exception, both stack traces can help debugging: the outer exception explains the application context, and the inner exception explains the original cause.
Because catch matching is type based, custom exception classes should be designed around categories that handlers can act on. Do not make a separate exception type for every possible sentence. Make a type when the type itself communicates a meaningful condition.
Common Mistakes
Throwing Plain Exception For A Specific Business Rule
try
{
throw new Exception("Enrollment is closed.");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
This compiles, but it gives callers no reliable type to catch. They would have to catch every Exception or inspect the message, both of which are fragile.
using System;
public class EnrollmentClosedException : Exception
{
public EnrollmentClosedException(string courseCode)
: base($"Enrollment is closed for {courseCode}.")
{
CourseCode = courseCode;
}
public string CourseCode { get; }
}
class Program
{
static void Main()
{
try
{
throw new EnrollmentClosedException("CSHARP101");
}
catch (EnrollmentClosedException ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine(ex.CourseCode);
}
}
}
Output:
Enrollment is closed for CSHARP101.
CSHARP101
Parsing Message Text Instead Of Using Properties
catch (InvalidOrderQuantityException ex)
{
if (ex.Message.Contains("25"))
{
Console.WriteLine("Special case");
}
}
Message text can change for clarity, localization, or formatting. Program decisions should use exception type and structured properties.
catch (InvalidOrderQuantityException ex)
{
if (ex.Quantity == 25)
{
Console.WriteLine("Special case");
}
}
Losing The Original Exception
catch (FormatException)
{
throw new CourseImportException("CSHARP101", 7, "Invalid import row.", null!);
}
The new exception has domain context, but the original cause is discarded. Pass the caught exception as the inner exception so logs can show the whole chain.
catch (FormatException ex)
{
throw new CourseImportException("CSHARP101", 7, "Invalid import row.", ex);
}
Best Practices
- Create a custom exception only when callers benefit from catching that exact type or reading extra structured data.
- Name custom exception classes with the
Exceptionsuffix, such asPaymentDeclinedException. - Derive directly or indirectly from
Exception; do not derive fromApplicationExceptionorSystemExceptionfor normal application code. - Provide the standard constructors unless the type is tightly controlled and domain-specific constructors are enough for your project.
- Use read-only properties for domain data, and avoid requiring handlers to parse
Message. - When wrapping another failure, pass the original exception as
innerException. - Keep messages clear, specific, and safe for logs. Do not include secrets such as passwords, tokens, or private keys.
- Throw built-in argument exceptions for invalid method parameters unless a domain-specific handler truly needs a custom type.
- Catch custom exceptions at a level that can do something useful, such as retry, display a domain message, return a validation result, or log context.
Practice Exercises
- Create an
InvalidGradeExceptionwith a read-onlyGradeproperty. Throw it when a grade is below0or above100, then catch it and print the invalid value. - Write a
DuplicateUsernameExceptionthat stores the username. Simulate registering"admin"twice and catch the custom exception. - Create a
ReportLoadExceptionthat wraps aFormatException. In the catch block, print the outer exception message and the inner exception type.
Summary
- Custom exceptions are ordinary classes that inherit from
Exception. - Use a custom exception when the failure has domain meaning that callers can handle separately.
- Exception handlers match by type, so a precise custom type is more reliable than checking message text.
- Add immutable properties for structured data that handlers need.
- Use
InnerExceptionwhen translating a lower-level failure into a higher-level domain failure. - Prefer built-in exceptions for common argument, state, format, and I/O problems.
- Good custom exceptions are specific, useful, and boring to handle: type, message, properties, and preserved cause.
