Помните ли вы о существовании goto?

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

Но почему-то я не встречал никакого негатива насчёт throw. А ведь это точно такая же фигня, если даже не хуже.

В моих личных проектах я их использую только тогда, когда там программа действительно должна ругнуться, например, на отсутствие обязательного файла или обязательного параметра атрибута. Когда программа даже не должна запускаться без этого или пользователь в лице разработчика сделал так, как не должно быть.

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

Минимум кода. Максимум удовольствия.
Выше скорость выполнения кода (try-catch работает медленнее).
Логика линейная и предсказуемая.

Что происходит, когда мы решаем, что выбрасывание исключения — отличная идея? Выбросил, где надо, и забыл про остальную часть кода. Где-то там поймал — и всё. Это имеет право на существование (но лучше, всё же, нет), если у вас не глубокая вложенность и какая-то простая API-шечка, у которой есть контроллеры, сервисы и репозитории — не потеряешься.

Но довелось мне работать на проекте с легаси, где вложенность превышает разумные пределы. Конечно, тогда ещё, возможно, не знали о возможности возврата ошибки вместо её выбрасывания, а возврат сразу двух значений выглядел не очень красиво:
var result = new Tuple<DataType, string>(data, error);

В итоге получилось так, что вызывается некий метод, далее всё это сильно ветвится, вызывая прочие методы. И разные методы выбрасывают разные типы исключений, а ещё некоторые из них отлавливаются и просто логируются, а некоторые пробрасываются дальше. Некоторые нигде не отлавливаются, а некоторые перебрасываются с новым текстом ошибки. В общем, работа с таким зоопарком исключений ничем не лучше, чем если бы туда напихали пачку goto. Может, даже проще было.

И я был бы рад это всё придумать, но мне каждый раз приходится самостоятельно анализировать все вызовы методов один за другим, чтобы понять, увидит ли конечный пользователь выброшенное сообщение или нет.

Уверен, что если у вас нечто похожее, то вы сами не знаете, как работает ваш код и, скорей всего, вы иногда пишите try-catch в коде "на всякий случай".

Моя идея проста: выбрасывайте исключения только там, где они действительно нужны. При бездумном использовании throw == goto.
Если же какая-то логика должна привести к сообщению об ошибке, не поленитесь и верните его из вашего метода — и ваш код станет чище, понятнее, его логика будет линейной и непрерывной.

В моих проектах try-catch можно по пальцам пересчитать. Естественно, есть глобальный. Есть в местах, где вызывается библиотечный метод, который может выкинуть исключение, если приходят неверные данные, а такая вероятность есть. Например, при вызове JsonSerializer.Deserialize(text);, где text — ответ от AI.

Вот пример того, как это можно сделать. Ниже всё, что нужно для реализации паттерна Result и даже чуть больше. Вы можете добавить что-то своё.

// когда хватило бы bool, но нужен ещё и текст ошибки
public class Result
{
    public ResultException Exception { get; set; }

    [JsonIgnore]
    public virtual bool IsSuccessful => Exception is null;

    [JsonConstructor]
    public Result() { }

    public Result(ResultException exception) => Exception = exception;

    public Result(ResultExceptionType exceptionType, string message = null, string additional = null) =>
        Exception = new ResultException(exceptionType, message, additional);

    public Result(Exception exception, ResultExceptionType exceptionType, string message = null, string additional = null) =>
        Exception = new ResultException(exception, exceptionType, message, additional);

    public static Result<T> ToResult<T>() =>
      IsSuccessful
          ? new Result<T>(default(T))
          : new Result<T>(Exception, Exception.ResultExceptionType, Exception.Message, Exception.Additional);

    // вместо пустого результата
    [JsonIgnore]
    public static Result Empty => new();
}

// данные или ошибка
public class Result<T> : Result
{
    public T? Data { get; set; }
    public bool IsNull { get; set; }  // иногда null - тоже результат

    [JsonConstructor]
    public Result() { }

    public Result(T? data)
    {
        Data = data;
        IsNull = Data == null;
    }

    public Result(ResultException exception) => Exception = exception;

    public Result(ResultExceptionType exceptionType, string? message = null, string? additional = null) =>
        Exception = new ResultException(exceptionType, message, additional);

    public Result(Exception exception, ResultExceptionType exceptionType, string? message = null, string? additional = null) =>
        Exception = new ResultException(exception, exceptionType, message, additional);

    public Result(T data, ResultExceptionType exceptionType, string? message = null, string? additional = null)
    {
        if (data == null)
            Exception = new ResultException(exceptionType, message, additional);
        else Data = data;
    }

    // удобно для мапинга
    public Result<TNew> NewResult<TNew>(Func<T, TNew> convertor) where TNew : new() =>
        IsSuccessful ? new Result<TNew>(convertor(Data)) : new Result<TNew>(Exception);

    [JsonIgnore]
    public override bool IsSuccessful => (Data != null || IsNull) && Exception == null;
}

// тип исключения, чтобы потом можно было обработать
public enum ResultExceptionType
{
    NotValid,
    NotFound,
    AlreadyExists,
    ...
}

// исключение
public class ResultException : Exception
{
    public string? Additional { get; }
    public ResultExceptionType ResultExceptionType { get; }

    public ResultException() { }

    public ResultException(ResultExceptionType resultExceptionType, string? message = null, string? additional = null) :
        base(message)
    {
        Additional = additional;
        ResultExceptionType = resultExceptionType;
    }

    public ResultException(Exception exception, ResultExceptionType resultExceptionType, string? message = null, string? additional = null) :
        base(message, exception)
    {
        Additional = additional;
        ResultExceptionType = resultExceptionType;
    }
}

Как это используется:

// Было
public SomeModel Get(int id)
{
    if (id < 0)
        throw new Exception("Wrong id");
    return repository.Get(id);
}

// Стало
public Result<SomeModel> Get(int id)
{
    if (id < 0)
        return new(ResultExceptionType.NotValid, "Wrong id"/*, "some_additional_data"*/)
    return repository.Get(id);  // если repository возвращает Result<SomeModel>
    // return repository.Get(id).ToResult<SomeModel>();  // если repository возвращает Result
}

-----------------------

// Было
[HttpGet]
public IActionResult Get(int id)
{
    try
    {
        var data = service.Get(id);  // тот самый Get() из примера выше
        return Ok(data);  // возвращает SomeModel
    }
    catch (Exception e)
    {
        logger.Log(e, ...);
        return BadRequest(e.Message);  // возвращает {"Message": "error_message"}
    }
}

// Стало
[HttpGet]
public IActionResult Get(int id)
{
    var result = service.Get(id);
    if (result.IsSuccessful)
        return Ok(result);  // или return Ok(result.Data);

    if (result.Exception.Type == ResultExceptionType.NotFound)
        return NotFound();
    else
        return BadRequest(result);  // или return BadRequest(result.Exception)
}

// Типичный запрос на добавление записи у меня выглядит так
[HttpPost]
public async Task<IActionResult> Insert([FromBody] SomeModelDto dto)
{
    if (!validator.Validate(dto, ModelState))
        return BadRequest(ModelState);

    return Ok(await service.Insert(dto));  // возвращает Result<SomeModelDto>
    // на клиенте уже проверяется наличие данных или исключения
}
// Да, это не RESTful API, но что ты мне сделаешь? Это мой pet project - как хочу, так и пишу.

Смотрите, как красиво, легко читается и предсказуемо выполняется!

Конечно, не нужно использовать Result также бездумно и где попало.
Если, например, обращение к БД должно вернуть коллекцию объектов, то и возвращайте коллекцию. Но если же коллекция не должна быть пустой, иначе ошибка, то это идеальное место для Result.

Хорошо читаемого вам кода!

Комментарии (14)


  1. Vitimbo
    02.08.2025 16:39

    Result, это такой же рак, как и возврат ошибок к golang. К тому же, при использовании "паттерна" Result, мы все равно должны использовать try catch на случай непредвиденных ошибок и обмазывать весь код гошным if err = nill.

    И ещё, можно не обрабатывать ошибки в методах контроллера, а ловить их прямо в Middleware. Тогда, кроме вызова метода и возврата результата, там не будет вообще ничего.


    1. ProgerMan Автор
      02.08.2025 16:39

      В статье я написал, что try-catch используется, но только там, где это действительно необходимо.

      Конечно, middleware на случай непредвиденных ошибок есть. Но всё, что можно предвидеть, уходит в Result. Проблема middleware в том, что он одинаково ловит и "wrong id", которое нужно показать пользователю, и null reference со stack trace, которое не нужно показывать пользователю. В моём коде туда попадает только то, что не должно попадать на глаза пользователю для логирования, отправки уведомления об ошибке и показа пользователю заглушки.


      1. Vitimbo
        02.08.2025 16:39

        Мы просто все 'наши' ошибки наследуем от некоего exception base. Если ошибка не наша, то просто идёт в логи, если наша, то возвращаем код, в зависимости от ошибки. Валидация так вообще сама по себе работает от фреймворка, не знаю, зачем тут эксепшены кидать.


  1. Politura
    02.08.2025 16:39

    throw == goto.

    И что? break в цикле это тоже goto, но если знаешь, что дальнейшие итерации не имеют смысла, то зачем вообще их делать? С исключениями точно также - если знаешь, что дальнейшая обработка запроса не имеет смысла, то зачем ее делать, зачем всем по цепочке вызовов проверять битый результат, когда можно сразу прыгнуть в самый конец?

    Далее, ваш пример:

    // Было
    [HttpGet]
    public IActionResult Get(int id)
    {
        try
        {
            var data = service.Get(id);  // тот самый Get() из примера выше
            return Ok(data);  // возвращает SomeModel
        }
        catch (Exception e)
        {
            logger.Log(e, ...);
            return BadRequest(e.Message);  // возвращает {"Message": "error_message"}
        }
    }
    
    // Стало
    [HttpGet]
    public IActionResult Get(int id)
    {
        var result = service.Get(id);
        if (result.IsSuccessful)
            return Ok(result);  // или return Ok(result.Data);
    
        if (result.Exception.Type == ResultExceptionType.NotFound)
            return NotFound();
        else
            return BadRequest(result);  // или return BadRequest(result.Exception)
    }
    

    Зачем????? Зачем вы делаете обработку ошибок в КАЖДОМ API эндпойнте???? Сделайте единый обработчик ошибок, сделайте разные типы исключений под разные варианты бизнеслогики, типа ValidationExeption, NotFoundExeption и тд, и пусть этот единый обработчик ими занимается. А эндпойнты будут выглядеть так:

    [HttpGet]
    public IActionResult Get(int id)
    {
        return Ok(service.Get(id));  // тот самый Get() из примера выше
    }
    

    И лучше чтоб он был асинхронным.


    1. ProgerMan Автор
      02.08.2025 16:39

      Это просто примеры, чтобы не перегружать не важными словами и кучей ненужных для этого классов. Каждый волен делать так, как пожелает.

      В простых API моно и бросать исключения. Но вложенность до вызова десятка методов один за другим и каждый может выбросить какое-то исключение, часть из которых логируется, часть игнорируется, часть выбрасывается дальше с новым текстом или даже новым типом, код превращается в ад. Читать его очень трудно, понять логику - ещё труднее. А потом пользователи говорят, что нажатие на одну кнопку возвращает осмысленный текст, а на другую похожую - заглушку. Потом оказывается, что одно и то же исключение в одном месте перехватывается и просто логируется где-то на полпути к контроллеру, а в другом - перебрасывается дальше.

      Получается, что у каждого такого метода может быть несколько состояний: вернул результат, выбросил ValidationException, выбросил NotFoundException, выбросил FooBarException, и т.д. И каждый надо учесть. У Result зависит от подхода. Можно всегда Ok(result) возвращать, можно разбить на Ok и BadResult, можно сделать больше и вообще на клиенте обрабатывать.


      1. Politura
        02.08.2025 16:39

        Получается, что у каждого такого метода может быть несколько состояний: вернул результат, выбросил ValidationException, выбросил NotFoundException, выбросил FooBarException, и т.д. И каждый надо учесть. 

        Зачем? В подавляющем большинстве случаев вы вообще нигде try/catch не делаете, только в одном едином обработчике ошибок. Везде по коду вы только бросаете исключения.

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

        И вы предлагаете заменить один ад другим, зачем? Надо исправлять, и не портить. Если бросаем исключение - то логгируем, перед тем как бросить. Все исключения которые бросаем - правильного типа, а не просто один общий Exception. Перепроверить места где исключения ловятся и затем опять бросаются - в подавляющем большинстве случаев этого не надо, есть несколько редких кейсов, их оставить. В обработчике ошибок самого верхнего уровня, который ловит все исключения, логгировать обязательно со стектрейсом.

        Все, проблемы решены, код чистый, всегда можно понять где что случилось. Я это говорю как человек десяток лет проработавший с несколькими большими легаси системами, где вложенность два-три десятка вызовов это обыденность, а классы встречаются размером за десять тысяч строк. Без эксепшенов там было-бы можно повеситься. С правильными эксепшенами код чище и багхантинг достаточно легкий.


  1. dmitriy_minaev
    02.08.2025 16:39

    goto не любят, потому что от него можно прыгнуть куда угодно по логике. И ситуация сильно ухудшается, если эти goto расставлены в нескольких местах и становятся перекрестными. Сравнивать throw с goto некорректно, так как throw явно ведёт либо вниз по коду, либо на какой-то уровень выше, то есть throw в этом смысле более линеен и предсказуем. Собственно, поэтому он и сделан: если есть большая вложенность и надо резко выпрыгнуть, минуя какую-то логику, которую, допустим, писали не вы. Да, его не нужно использовать часто, это обработчик специфических ситуаций и я тоже предпочитаю чаще использовать return, но это не всегда оправдано.

    Вообще, я напомню, throw вводился для отделения логики работы от логики обработки ошибок. Т.е. в теории с ним более чистый код.


  1. panzerfaust
    02.08.2025 16:39

    Нет, декларативная обработка ошибок через монады не серебряная пуля. На практике код с Result/Either/Optional в сложной логике тоже превращается в кашу.


  1. NeoNN
    02.08.2025 16:39

    Result success/fail, и его дальнейшее развитие в виде functional extensions хороши в том случае, когда мы делаем цепочку логики, из которой можем вывалиться с ошибкой на любом шаге, так называемое railway-oriented программирование. Про него в C# очень хорошо Владимир Хориков рассказывал. А исключения на то и исключения, что ими не следует делать второй контур логики, вводя кучу пользовательских и обрабатывая их то тут, то там, что приводит к разрушению связности кода и появлению неявных зависимостей. В основном такая претензия к исключениям, когда их не по назначению используют.


    1. MountainGoat
      02.08.2025 16:39

      Исключения используют крайне избыточно. Лично у меня есть правило, которое в грубом виде звучит так: обработчик исключения всегда должен заканчиваться на sys:exit(). Если событие не повод останавливать процесс или хотя бы поток, то это и не повод использовать исключение.

      А некоторые популярные библиотеки наоборот считают исключения решением всего. Функция имеет 3 режима работы? Добавим ей аргумент int mode и исключение, если mode > 2. С точки зрения архитектуры, это орки писали.

      Невалидные состояния системы должны не иметь валидного представления в коде. Это классическая школа проектирования, и в ней не нужны исключения. Если открываешь файл, а он не открывается, то поступать можно так: ввести два типа: ОткрытыйФайл и НеоткрывшийсяФайл. И функция открытия файла возвращает одно из двух. Потом результат надо различить по типу. И все функции работы с файлом принимают только ОткрытыйФайл. Всё. Ошибится - невозможно, компилятор не даст. Поток выполнения не нарушается. Компилятору легко оптимизировать.


  1. php7
    02.08.2025 16:39

    А if без {} в вашем языке не дурной тон?


    1. voidinvader
      02.08.2025 16:39

      Общего консенсуса нет, да и многие фичи добавляли в C#, по ощущениям, чтобы угодить сразу всем и через синтаксический (и не только) сахар переманить на язык как можно больше разработчиков.


  1. php7
    02.08.2025 16:39

    В вашем языке goto может прыгнуть куда угодно?

    В моем возможны только в том же контексте. То есть внутрь функции или цикла не прыгнешь.


  1. impwx
    02.08.2025 16:39

    Без поддержки со стороны языка и фреймворка подход с Result превращается в гору бойлерплейта и не дает никаких гарантий, т.к. методы BCL все равно кидают исключения. А еще может быть ситуация, когда у вас много реализаций Result, например из двух разных NuGet-библиотек и еще ваша собственная, и они не совместимы между собой...