Что такое пользовательское (кастомное) исключение в C# и когда его создавать?

Ответ

Пользовательское исключение — это класс, унаследованный от System.Exception или его производных, предназначенный для представления специфичных для предметной области ошибок.

Зачем создавать свои исключения?

  1. Семантика и ясность: throw new OrderNotFoundException(...) понятнее, чем throw new KeyNotFoundException(...).
  2. Группировка обработки: Можно ловить конкретный тип исключения (catch (PaymentFailedException ex)).
  3. Дополнительные данные: Возможность добавить свойства с контекстом ошибки (ID заказа, код ошибки API).

Правила создания:

  1. Наследоваться от Exception (или ApplicationException, но это менее распространено).
  2. Добавлять стандартные конструкторы.
  3. Помечать класс атрибутом [Serializable], если исключение может пересекать границы доменов приложения (например, в Remoting или WCF).

Пример правильно оформленного кастомного исключения:

[Serializable] // Для поддержки сериализации
public class InsufficientFundsException : Exception
{
    // Дополнительные свойства для контекста
    public string AccountId { get; }
    public decimal CurrentBalance { get; }
    public decimal RequiredAmount { get; }

    // Стандартные конструкторы
    public InsufficientFundsException() { }
    public InsufficientFundsException(string message) : base(message) { }
    public InsufficientFundsException(string message, Exception inner) : base(message, inner) { }

    // Конструктор с дополнительными данными
    public InsufficientFundsException(string accountId, decimal currentBalance, decimal requiredAmount)
        : base($"Account '{accountId}' has insufficient funds. Current: {currentBalance}, Required: {requiredAmount}")
    {
        AccountId = accountId;
        CurrentBalance = currentBalance;
        RequiredAmount = requiredAmount;
    }

    // Конструктор для десериализации (важно для [Serializable])
    protected InsufficientFundsException(
      System.Runtime.Serialization.SerializationInfo info,
      System.Runtime.Serialization.StreamingContext context) : base(info, context)
    {
        AccountId = info.GetString(nameof(AccountId));
        CurrentBalance = info.GetDecimal(nameof(CurrentBalance));
        RequiredAmount = info.GetDecimal(nameof(RequiredAmount));
    }
}

// Использование в бизнес-логике
public void ProcessWithdrawal(string accountId, decimal amount)
{
    var balance = GetBalance(accountId);
    if (balance < amount)
    {
        // Бросаем информативное исключение с контекстом
        throw new InsufficientFundsException(accountId, balance, amount);
    }
    // ... продолжение обработки
}

Рекомендация: Не создавайте новые исключения для ситуаций, которые уже точно описываются встроенными типами (ArgumentNullException, InvalidOperationException).

Ответ 18+ 🔞

А, ну вот, смотри, опять про эти ваши исключения кастомные. Ну ладно, раз уж спросил, объясню по-человечьи, а то в документации какую-то хуйню пишут, мозги выносят.

Представь, ты пишешь банковскую систему. И у тебя клиент пытается снять бабла больше, чем у него на счету. Можно, конечно, тупо выкинуть InvalidOperationException с текстом "Недостаточно средств". И что? А потом другой программист в другом конце кода будет ловить это исключение и думать: "Бля, а это что за невалидная операция? Может, я не тот метод вызвал? Или аргументы кривые?" Пиздец, короче, неочевидно.

Вот для этого и нужны свои исключения. Чтобы было понятно с полпинка, какая именно хрень случилась. throw new InsufficientFundsException(...) — это же сразу ясно, ёпта! Деньги кончились, вот и вся история. И ловить его можно точечно, не цепляя другие левые ошибки.

Как это правильно делать, чтобы не выглядеть как полный криворук:

  1. Наследуйся от Exception. Это база, отец родной всех ошибок. От ApplicationException можно, но это уже такой старый дед, что его все забыли, лучше от обычного Exception.
  2. Стандартные конструкторы напиши, блядь. Их там четыре штуки обычно. Это как хороший тон, чтобы твое исключение могло вписаться в общую тусовку .NET.
  3. Атрибут [Serializable]. Это если твоё исключение может путешествовать между разными процессами, доменами или вообще по сети. Если не уверен — ставь, хуже не будет. Но если поставил, не забудь про конструктор для десериализации, а то при распаковке получишь ещё одну ошибку поверх первой — вот это будет пиздец, да.

Главное — не выебывайся. Не создавай исключение UserPressedWrongButtonIdiotException. Для дурацких ситуаций уже есть нормальные встроенные типы. Используй ArgumentNullException, когда тебе передали null вместо объекта, или InvalidOperationException, когда состояние системы не позволяет сделать операцию. Свои — только для действительно уникальных, предметных ошибок твоего приложения.

Вот смотри, как это выглядит в коде, когда всё сделано по уму:

[Serializable] // На всякий пожарный, пусть будет сериализуемым
public class InsufficientFundsException : Exception
{
    // Это наши фишки, которых в обычном Exception нет
    public string AccountId { get; }
    public decimal CurrentBalance { get; }
    public decimal RequiredAmount { get; }

    // Обязательные конструкторы, как у всех приличных исключений
    public InsufficientFundsException() { }
    public InsufficientFundsException(string message) : base(message) { }
    public InsufficientFundsException(string message, Exception inner) : base(message, inner) { }

    // А вот это — наш красавец, главный конструктор
    public InsufficientFundsException(string accountId, decimal currentBalance, decimal requiredAmount)
        : base($"На счету '{accountId}' нихуя не хватает. Сейчас: {currentBalance}, Надо: {requiredAmount}")
    {
        AccountId = accountId;
        CurrentBalance = currentBalance;
        RequiredAmount = requiredAmount;
    }

    // А это магия для сериализации. Если есть [Serializable], то и это нужно, иначе всё разъёбется при передаче.
    protected InsufficientFundsException(
      System.Runtime.Serialization.SerializationInfo info,
      System.Runtime.Serialization.StreamingContext context) : base(info, context)
    {
        AccountId = info.GetString(nameof(AccountId));
        CurrentBalance = info.GetDecimal(nameof(CurrentBalance));
        RequiredAmount = info.GetDecimal(nameof(RequiredAmount));
    }
}

// А вот как этим пользоваться в жизни
public void СнятьБаблища(string accountId, decimal amount)
{
    var balance = УзнатьБаланс(accountId);
    if (balance < amount)
    {
        // Всё, кидаем наше красивое, информативное исключение
        throw new InsufficientFundsException(accountId, balance, amount);
    }
    // ... если денег хватает, то снимаем дальше
}

Видишь? Теперь любой, кто поймает InsufficientFundsException, сразу поймёт, в чём дело, и даже сможет достать из него AccountId, чтобы пользователю внятную ошибку показать. А не гадать, что за ебучий InvalidOperationException вылез.

Короче, смысл в том, чтобы код стал понятнее, а отладка — проще. Не плоди сущностей без необходимости, но и не тупи, используя Exception на все случаи жизни.