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

  1. Your custom exception class is compiled like any other class, with metadata saying it derives from Exception.
  2. Code creates the exception object with new, passing any message, inner exception, or domain values to the constructor.
  3. The constructor calls a base Exception constructor, which stores the message and inner exception.
  4. When throw runs, normal execution stops and the CLR begins stack unwinding.
  5. The CLR compares the thrown object’s runtime type with each nearby catch type. Exact matches and base-class matches are compatible.
  6. If a matching handler is found, the handler receives the same exception object, including its custom properties.
  7. 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 Exception suffix, such as PaymentDeclinedException.
  • Derive directly or indirectly from Exception; do not derive from ApplicationException or SystemException for 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

  1. Create an InvalidGradeException with a read-only Grade property. Throw it when a grade is below 0 or above 100, then catch it and print the invalid value.
  2. Write a DuplicateUsernameException that stores the username. Simulate registering "admin" twice and catch the custom exception.
  3. Create a ReportLoadException that wraps a FormatException. 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 InnerException when 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.