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

В первую очередь следует понимать, что исключения — это синтаксический сахар. Реализовать обработку ошибок можно и обычными if...else, но кода будет намного больше. А если механизм исключений используется неправильно, то преимущества такого синтаксического сахара просто не используются, вы получаете тот же if...else, только обогащаете код «ненужным в этом случае синтаксисом».

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

Например, в следующем коде определяется собственное исключение, сообщающее, что ресурс не найден. По типу исключения мы понимаем его суть, а по полям user и resourceId мы понимаем, кто пытался запросить ресурс и какой именно. Имея такую информацию в логах, становится сильно проще разбираться в причинах проблемы.

public class ResourceNotFoundException extends RuntimeException {
  private final User user;
  private final String resourceId;

  public ResourceNotFoundException(String resourceId, User user) {
    super("Ресурс с id " + resourceId + " не найден. Пользователь: " + user.toString());
    this.resourceId = resourceId;
  }
}

В чем заключается синтаксический сахар исключений? А в том, что в цепочке вызовов методов не требуется прокидывать объект с информацией об ошибке. Между throw и try‑catch может быть сколько угодно промежуточных методов, которые ничего не будут знать об этом исключении.

Цепочка методов, через которые перебрасывается исключение
Цепочка методов, через которые перебрасывается исключение

Метод, организующий try‑catch, несет ответственность за обработку заданных исключений. Очень важно навешивать ответственность за обработку исключений архитектурно правильно для прозрачности кода. Более того, если метод несет ответственность за обработку исключений, он больше не должен на себя брать никакой ответственности, то есть логики в нем не должно быть.

Взглянем на следующий код:

public T execute(Object[] params) {
  try {
    Task task = generateTask();
    String id = task.id;
    if (params.size() > 0) {
      fixParams(id, params);
      return task.run(params);
    } else {
      return task.run();
    }
  } catch (Exception e) {
    log(e.getMessage());
  }
}

В этом коде прослеживается логика внутри блока try. Это затрудняет чтение особенно из‑за возникшего внутреннего условного оператора — дополнительный уровень вложенности.

А вот так должен выглядеть метод, который несет ответственность только за обработку исключений:

public T execute(Object[] params) {
  try {
    executeTask(params);
  } catch (Exception e) {
    log(e.getMessage());
  }
}

Метод, организующий try catch блок, является замыкающим: вся цепочка методов, вызываемых после него, не имеет try catch блока совсем — там реализуется исключительно бизнес‑логика. Согласитесь, намного приятнее выглядит метод прошлого примера без try‑catch блока?

public T executeTask(Object[] params) {
  Task task = generateTask();
  String id = task.id;
  if (params.size() > 0) {
    fixParams(id, params);
    return task.run(params);
  } else {
    return task.run();
  }
}

Метод должен иметь минимальное количество уровней вложенности: лишний вложенный if уже затрудняет чтение, так же как и блок try. Идеальная структура метода, несущего ответственность за обработку исключений, выглядит следующим образом:

// Реализация метода
try {
  // Вызов метода-обработчика (одна строка)
} catch (MyException e) {
  // Логика обработки исключения (любое число строк)
} catch (MyException2 e) {
  // Логика обработки исключения 2 (любое число строк)
...
} catch (MyExceptionN e) {
  // Логика обработки исключения N (любое число строк)
}

В таком методе акцент только на обработке исключений. В блоке try ничего нет кроме вызова какой‑то операции. При этом обработка отдельного исключения реализуется одним из следующих вариантов:

  • Логирование. Метод замыкает цепочку вызовов, организуя запись в лог подробной информации об ошибке. Обычно реализуется сервисами, которые не должны падать в случае ошибок, но сообщать информацию разработчикам о том, что что‑то пошло не так.

  • Переброс исключения. Метод до‑обогащает исключение дополнительной информации, которая отсутствует в пойманном исключении. К примеру, для какого ID процесса произошла проблема. Переброс недопустим, если до‑обогащать новой информацией исключение не требуется. Единственное, в редких случаях допускается пере‑выбрасывать Exception в RuntimeException, чтобы устранить лишние throws.

  • Определение другого сценария поведения. Имеется ввиду, что в блоке catch реализуется полноценная логика. Например, не удалось преобразовать строку в json, поэтому в блоке catch идем по другому сценарию, где обрабатывается чистая строка. Такой вариант обработки исключений по возможности рекомендуется исключать, а пользоваться признаками или флагами для понимания, в какой сценарий необходимо уйти. Использование такого подхода сильно усложняет код, поскольку ожидаем обработку ошибки, а в итоге уходим в другой сценарий, как будто это ветка else.

  • Формирование результата. Если метод должен в любом случае вернуть какой‑то результат независимо от исключения, тогда в блоке catch может быть реализована логика формирования специфического ответа метода в случае ошибки. Такое часто применяется для REST, когда клиент в любом случае должен получить ответ. Тот же Spring благодаря магии рефлексии обеспечивает лаконичность благодаря handler‑ам исключений: мы вообще не пишем try‑catch, мы объявляем handler — отдельный метод, который вызывается в случае заданного исключения.

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

Логирование

try {
  // ...
} catch (MyException e) {
  // Логирование в случае получения кастомного исключения (пишем все доступные поля в лог)
  log(buildMessage(e.getProcessId(), e.getMessage()));
} catch (Exception e) {
  // Логирование всех остальных исключений с менее подробной информацией 
  // (исключения, которые разработчик не предусмотрел)
  log(e.getMessage());
}

Переброс исключения

try {
  // ...
} catch (ArgumentException e) {
  // Переброс исключения, до-обогащая processId
  throw new MyException(processId, e.getMessage());
}

Определение другого сценария поведения

try {
  JsonNode json = parseToJson(content);
  processJson(json);
} catch (ArgumentException e) {
  // Обработка строки
  processString(content);
}

Формирование результата

try {
  // Возвращаем успешный результат
  return ResponseEntity.body(execute(id));
} catch (Exception e) {
  // Возвращаем тело ошибки
  return ResponseEntity.body(buildError(e));
}

Для максимальной прозрачности кода рекомендуется документировать методы вместе с возможными исключениями, которые могут быть важны для пользователя. В Java мы можем использовать @throws для документации:

/**
 * Преобразовать перечень элементов в список строк
 * @param items Перечень элементов
 * @return Скомпонованный список
 * @throws NullPointerException Передан нулевой элемент или список
 * @throws IncorrectJsonException Не удается преобразовать в json элемент
 */
public ArrayList<String> flat(Iterable items, ObjectMapper mapper)

Теперь, если мы захотим вызвать этот метод, мы сразу увидим, к чему быть готовым. При этом указание исключения вовсе не означает, что надо его обрабатывать, мы можем выполнить предварительные проверки. Например, нас предупредили, что может быть NullPointerException, значит, нам надо убедиться, что наш код никак не передаст в качестве items null. То же касается и IncorrectJsonException — вероятно, мы проверки сделаем предварительно, и не придется выполнять try‑catch.

if (items != null)
  // Гарантируем, что items не равен null (try catch не нужен для NullPointerException)
  return flat(items, objectMapper);
else
  return new ArrayList<>();

Ключевое слово throws для объявляемых исключений, к сожалению, часто создает загромождение в коде. Иногда мы гарантируем, что исключение не может быть выброшено бизнес‑логикой, но ключевое слово throws все равно вынуждает нас писать try catch. В этом плане даже Роберт Мартин в своей книге «Чистый код» говорит о ненужности throws и рекомендует не работать с объявляемыми исключениями — пере‑выбрасывать в не объявляемые. Код ниже демонстрирует сигнатуру метода, который вынуждает обработать JsonProcessingException.

// Вынуждаем снаружи обрабатывать исключение JsonProcessingException, хотя наш код
// мог бы гарантировать, что такого исключения не может быть
public ArrayList<String> flat(Iterable items, ObjectMapper mapper) 
  throws JsonProcessingException

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

Хороших всем разработок!

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


  1. yursdan
    15.06.2026 22:14

    Странный выбор уровня "Средний" без упоминания и раскрытия тем AutoCloseable под конструкцию try-with-resources и важной темы того, что throw это сравнительно дорогостоящее действие, которое в нагруженных системах по возможности избегают, если накладные расходы в коде (громоздкость/сложность) это позволяют, например: Mono.error c throwable, которое не будет throw в реакте.


  1. LiamBlue
    15.06.2026 22:14

    В C++ исключения не такой уж и синтаксический сахар. Попробуйте в конструкторах обойтись без них. Также, исключения неудобно сочетать с многопоточностью. Как их перебрасывать между потоками?


    1. AbitLogic
      15.06.2026 22:14

      В Rust обходятся без исключений, в C++ аналоги тоже есть - std::optional и std::expected, менее удобные из-за отсутствия паттерн-матчинга, но функционально они соответствуют монадам Option и Result, поэтому не вижу проблем обходится без исключений


  1. mvuhanov
    15.06.2026 22:14

    Механизм исключений это вовсе не синтаксический сахар. И уж конечно не замена if-else.

    Огромное количество ключевых слов, вводимых и обрабатываемых VM: try-catch-finally, throws, throw.

    Дополнительно VM различает Exception и Error. А так же RuntimeException и его наследников, от unchecked exceptions (остальных).

    Обработка исключения это затратная по времени операция, почему?

    Потому, что нужно сохранить контекст для дальнейшего правильного выполнения кода, например - рассмотрите полный блок try с return в каждой ветке. Плюс нужно отложить выброс throw исключения при наличии блока finally - сначала выполнить его. А если в нем тоже throw?!

    Дополнительно, нужно правильно раскрутить обратно стэк, при большом количестве вложений методов.

    Я бы добавил в статью все эти детали, чтобы глубже раскрыть всю красоту и силу механизма исключений.


  1. sceptizator
    15.06.2026 22:14

    В exceptions на самом деле ничего особо правильного и элегантного нет. Это лишь "модернизированная" форма GOTO. Причем с т.з. зрения современного программирования безнадежно устаревшая, примерно как как и UTF-16 или UTF-32

    В более современных Go, Rust, Zig от exceptions отказались намеренно, заменив на монады с парой <Result>, enum <ErrorCode>, проверяемые по

    switch (ErrorCode) {

    case :

    case :

    }

    По элементарной причине - при добавлении нового типа ошибки нужно получить от компилятора Warnings, что вот тут и тут нарушается контракт, у вас пропущены жестко перечисляемые секции с case и этот новый код из соответствующего enum у вас вот тут и тут не обрабатывается, допишите пожалуйста вот там и там условия для обработки, чтоб не нарушать контракты.

    Классический пример: функция recv(), получения сетевых пакетов. Вот неожиданно появился новый тип errno==EGAIN на блокирующися сокетах (в Windows его нет, в Linux есть), и теперь нужно пойти все вызовы recv() перепроверить и дописать повторные обработки, т.к. это и не ошибка вовсе, а законный поток управления - нужно не падать и не плакать в лог, а просто сделать вызов еще раз.

    В Java такое с проверяемыми компилятором контрактами в теории тоже было бы возможно, если бы для каждого кода ошибки делали бы свои классы исключений. Но глядя на SQLException.getErrorCode() и подобные понимаешь, что никакой элегантности там уже никогда не будет, корректность принесена в жертву обратной совместимости. Может и хотели как лучше, а получилось как всегда.

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

    И это мы еще про panic() и fail fast не поговорили.