Всем привет! Меня зовут Илья, я работаю в Райффайзен Банке. Мы пишем свои бэкенд-сервисы на Java и Kotlin, поэтому зачастую приходится переключаться с одного языка на другой. Из-за этого невольно начинаешь сравнивать подходы и механизмы одного языка с его JVM-собратом. Сегодня я бы хотел поговорить об одном из таких механизмов — пропагации ошибок и исключений.
Используете ли вы в своем коде исключения? Ответ кажется странным, так как исключения являются неотъемлемой частью Java. Но что, если я спрошу, используете ли вы исключения для управления логикой своей программы?

Дисклеймер:
В данной статье при использовании слова «Ошибка» или «Исключение», я обычно буду иметь в виду логическую ошибку. Например, только заявка на ипотеку не найдена в Базе Данных, в противовес технических ошибок: когда мы вызвали удаленный веб-сервис и упали с таймаутом, так и не дождавшись ответа.
Для начала давайте вспомним, какие вообще способы контроля флоу программы есть у Java:
Условия
if/else
switch/when
Циклы
fori/foreach
do while/while do
Остальные
GOTOlabels + break/continuethrow/try/catch (Exception e)/finally
return ResultOrError (Typed Errors)
Первые 2 группы работают в пределах одной функции, так что давайте не будем их рассматривать. Break и Continue по большей части тоже, да и используются они редко.
У нас остаются «Исключения» и «Typed Errors». Рассмотрим для начала «Исключения» и попробуем разобраться, как с ними работать в рамках контроля поведения нашей логики.
Исключения
Начнем с «Исключений». Документация Java приводит нам такое определение:
Definition: An exception is an event, which occurs during the execution of a program, that disrupts the normal flow of the program's instructions
Ключевое слово здесь «disrupts», то есть «прерывает». Мы можем в любом месте нашего метода прервать выполнение и выйти из него без результата. Более того, мы будем прерывать все функции по стеку вызовов, пока не встретим обработчик исключений.
Рассмотрим пример:
public User create(String name, int age) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException("Name is null or empty");
}
if (age < 18) {
throw new IllegalArgumentException("Age is less than 18");
}
return new User(name, age);
}
На первый взгляд ничего криминального — на некорректное имя или возраст пользователя мы кидаем исключение, суть которого — выбрасываться там, где аргумент был некорректным. А кто обработает его на вызывающей стороне?
С одной стороны, неважно, ведь мы написали метод, а уже кто и как будет обрабатывать исключение — не наша забота!
Но давайте будем сознательными разработчиками и подумаем, сможет ли клиент этого метода понять, что будет кинут IllegalArgumentException
? Нет, так как это Unchecked Exception, а следовательно, компилятор никак не поможет, и нас может ждать неожиданная ситуация прямо в рантайме или даже на проде.
Кстати, документация Java дает интересное определение для Unchecked Exception.
These are exceptional conditions that are internal to the application, and that the application usually cannot anticipate or recover from. These usually indicate programming bugs, such as logic errors or improper use of an API
Как будто, improper use of an API наш вариант, но остается проблема с тем, что клиент этого метода никак не заметит (без изучения исходного кода), что такая ситуация может произойти. Поэтому давайте немного перепишем:
public User create(String name, int age) throws CustomIllegalArgumentException {
if (name == null || name.isEmpty()) {
throw new CustomIllegalArgumentException("Name is null or empty");
}
if (age < 18) {
throw new CustomIllegalArgumentException("Age is less than 18");
}
return new User(name, age);
}
Для этого нам придётся создать новый Checked Exception (проверяемые исключения):
public class CustomIllegalArgumentException extends Exception {
public CustomIllegalArgumentException(String message) {
super(message);
}
}
Checked Exception заставит компилятор удостовериться, что клиент что-то сделает с этим исключением. Как это происходит? Из документации мы узнаем, что есть некий Catch or Specify Requirement. Он говорит о том, что теперь как клиент, мы обязаны или поймать (обработать) исключение, или пробросить его дальше по стеку вызовов.
В итоге мы получим на вызывающей стороне такую картину:
try {
userService.create("Петя", 15);
} catch (CustomIllegalArgumentException e) {
logger.error("Error! User invalid!", e);
}
Выглядит солидно! Исключение выбрасывается, мы его ловим и делаем кастомную логику на случай, когда пользователь не может быть создан. Но будем ли мы уверены в том, что клиентская сторона обработает этот случай, а не сделает вот так?
public void create() throws CustomIllegalArgumentException {
userService.create("Петя", 15);
}
С мыслями: а я не знаю, что делать, когда пользователь некорректный, надеюсь, выше по стеку вызовов знают! Или вообще, может, ну его, этот try/catch, некрасивый он и громоздкий!
А что обычно Java-разработчики делают с громоздкими участками кода? Правильно! Стараются спрятать его подальше от глаз. Например, библиотека Lombok позволяет на этапе компиляции убрать, добавить или модифицировать некоторый код, например, сгенерировать getters/setters/constructors.
О! Аннотация @SneakyThrows — как раз то, что нужно. Она делает так, что javac теперь не будет ругаться на то, что мы никак не обработали или не прокинули выше по стеку наш CustomIllegalArgumentException
.
@SneakyThrows
public void create() {
userService.create("Петя", 15);
}
Во! Красота! А случай с некорректным пользователем редкий, и он случится не на моем веку.
В итоге получается такая картина:

Но почему создатели Lombok придумали аннотацию, которая обходит такую фундаментальную фичу Java, как Checked Exception? Разве они что-то плохое, и мы должны их обходить? Мы уверены, что создатели языков программирования — люди с гигантским опытом и знаниями — не могут создать неудачное решение.
К сожалению, это не всегда так.
Создатель Java однажды сказал о Checked Exception:
You can’t accidentally say, ‘I don’t care.’ You have to explicitly say, ‘I don’t care
James Gosling
Его задумка была в том, чтобы мы, как разработчики, или обрабатывали исключения, или явно декларировали в своем коде: «Я не хочу этого делать, мне плевать». В итоге мы имеем то, что имеем: практически все новые исключения создаются наследниками от RuntimeException — таким образом, их не обязательно ловить, а те Checked Exception, что уже есть в Java, не обрабатываются должным образом. Согласитесь, такая картина не редкость:
static void closeChannel(@Nullable Channel channel) {
if (channel != null && channel.isOpen()) {
try {
channel.close();
}
catch (IOException ignored) {
}
}
}
Если вы думаете, что это мой код или код какого-то Vasyan666 с GitHub, то вы ошибаетесь — это метод org.springframework.core.io.buffer.DataBufferUtils#closeChannel
из нашего любимого фреймворка Spring.
Из-за этого, глядя на печальный опыт Java, создатели многих языков программирования решили вообще не включать Checked Exception в свой язык. Они понимали, что это лишь усложнит жизнь обычным разработчикам и оттолкнет их от использования языка.
Но есть ли какое-то решение?
Например, в языке Go при вызове функции возвращается не только результат, но и ошибка err. При этом код начинает пестрить лишними проверками, так как не каждый вызов функции оканчивается ошибкой.
Так что разные языки по-своему решают проблему обработки ошибок. Одни отказываются от проверяемых исключений, другие делают обработку ошибок частью сигнатуры функции. Но такой подход тоже не лишён недостатков. Код превращается в череду однотипных проверок.
Из-за этого в мире языков, которые реализуют функциональную парадигму программирования, появилось понятие Typed Errors.
Typed Errors
Для начала приведем определение:
Typed errors refer to a technique from functional programming in which we make explicit in the signature (or type) the potential errors that may arise during the execution of a piece of code
Простыми словами, возвращаемый тип функции теперь содержит не только ее результат, но и/или ошибку.
Например, в Scala и Haskel это реализовано через класс Either стандартной библиотеки. Но так как в нашем банке эти языки не пользуются особой популярностью, рассмотрим технику Typed Errors на языке Kotlin. Он уже содержит класс kotlin.Result, который в зависимости от контекста может хранить или result или throwable.
Однако разработчики пошли дальше и расширили эту функциональность в библиотеке ArrowKt. Давайте воспользуемся ей и перепишем наш пример с валидацией пользователя:
fun create(name: String?, age: Int) = either<Error, User> {
if (name.isNullOrEmpty()) return Error("Name is null or empty").left()
if (age < 18) return Error("Age is less than 18").left()
return User(name, age).right()
}
Разберемся, что у нас изменилось:
Теперь в функции мы возвращаем не User, а
монадувраппер-класс Either. Это один из двух логических результатов нашей функции — успех (тип User) и ошибка (тип Error). Так как в compile-time мы не знаем результата, в этом классе сейчас содержится сразу 2 типа, но в run-time мы увидим только один.На каждом return мы не просто создаем результат, а преобразуем его в Either-тип — успех или ошибка с помощью функций right() и left(). Обычно справа у нас обычный результат функции, а вот левая часть — это наша TypedError, в которой может лежать любой тип, даже Exception.
Все тело метода теперь — это блок с типом Raise<Error>.() -> A (продюсер объекта типа A с ресивером типа Raise<Error>). Если вам стало не по себе от предыдущего предложения, ничего страшного, это значит лишь то, что мы теперь можем вызывать в нашем блоке функции вроде raise(Error(«Name is null or empty»)) и поддерживать вложенные функции, которые возвращают Either.
Давайте как раз рассмотрим такой вариант (и заодно заиспользуем удобную функцию ensure):
fun create(name: String?, age: Int) = either<Error, User> {
val newName = createName(name).bind()
val newAge = createAge(age).bind()
User(newName, newAge)
}
fun createName(name: String?) = either<Error, String> {
return ensure(!name.isNullOrEmpty()) { Error("Name is null or empty") } else name.right()
}
fun createAge(age: Int) = either<Error, Int> {
return ensure(age > 18) { Error("Age is less than 18") } else age.right()
}
В данном случае при ошибке Name is null or empty мы сразу свяжем Error из функции createName с функцией create с помощью функции bind() и сразу вернем результат, не заходя в функцию createAge. Функция bind() имеет еще одно предназначение — она сигнализирует, что из какой-то функции возвращается не просто результат, а Either.
Погоди, скажете вы. Это те же checked exceptions, только вид сбоку!

На это я могу сказать следующее:
Нельзя просто так взять™ и создать проверяемое исключение. Для этого JVM нужно собрать весь stack trace от начала до конца, что требует некоторых ресурсов и процессорного времени. На деле это не совсем так и имеют место различные оптимизации — подробнее в докладе Владимира Ситникова.
В то время как проверяемые исключения обособленный механизм Java, Typed Errors встроены в return-type, а не существуют от него отдельно. Это значит, что мы можем использовать с ними все инструменты нашего языка: циклы, условия, дженерики и паттерн-матчинг. Представьте себе стрим, где первым делом вы маппите элемент и получаете результат или, в плохом случае, «Исключение». Впоследствии вам нужно вывести результаты в консоль. Проблема возникнет на функции map, которая по сигнатуре требует Function<A, B>! Как говорится, исключение в сделку не входило:
List.of("a.txt", "b.txt", "c.txt").stream()
.map(fileName -> FileUtils.readLines(new File("~/" + fileName)))// ой-ой Unhandled exception: java.io.IOException
.forEach(System.out::println);
3.Type Errors имеют мощную поддержку со стороны библиотеки в плане различных helper-функций, которые формируют своего рода DSL, например:
fun main() {
val user = create("Vasya", 19)
.map { user -> user.copy(surname = "Petrov") }
.onLeft { println("Error! $it");return }
.getOrNull()!! // NPE? Never
println("User $user created!")
}
В данном случае мы строим функциональную цепочку, которая говорит нам о том, что:
Если результат функции create - right, то измени его и добавь поле surname = "Petrov". Далее разверни either и распечатай готовый результат в консоль.
Если же результат - left, напечатай ошибку;
В итоге получим:
Error! java.lang.Error: Age is less than 18
Или:
User User(name=Vasya, age=19, surname=Petrov) created!
Более подробные примеры и дополнительные фичи библиотеки можно посмотреть прямо в документации.
Typed Errors: the bad parts
Ну все, бежим прикручивать к Kotlin-проектам эту волшебную библиотеку и жить долго и счастливо? Давайте я добавлю пару ложек дегтя, которые основаны на реальном использовании такого подхода в классических Spring Boot проектах:
Транзакции. Давайте вспомним один из самых популярных вопросов на интервью: «Как работает @Transactional?». При вызове метода, сначала вызывается аспект, который до вызова делает createTransactionIfNecessary(), а после commitTransactionAfterReturning(). Что же заставит транзакцию откатиться? Правильно — исключение, которое будет поймано в одном из блоков try/catch. Как ни странно, с Typed Error это не сработает. Затейники со Stack Overflow уже придумали фикс, но он кажется страшнее, чем сама проблема. В теории, можно сделать вызов
TransactionInterceptor.currentTransactionStatus().setRollbackOnly()
, но это уже вопрос того, как вручную заставить транзакцию откатиться.Конструкторы. Настоящие программисты™ пишут программы согласно доменной модели, а настоящая модель имеет правила валидации, и мы не сможем создать объект, если его параметры не удовлетворяют этой валидации. По канону, мы должны возвратить Either из конструктора, но по правилам языка — это запрещено. К счастью, создатели библиотеки придумали остроумный финт ушами, который решит эту проблему, но сделает ваш код чуточку замудреннее.
Забытый bind(). Допустим, у нас есть подобный код:
either<String, String> {
sendMessage()
“Success”
}
fun sendMessage(): Either<String, Unit> = either { raise("Error") }
На первый взгляд все ОКЕЙ, мы ожидаем, что main вернет нам Left, в котором содержится наш Error, но в действительности мы вернем Right с Success! Мы забыли вызвать bind на sendMessage! И что самое обидное, этот код спокойно компилируется и запускается. Поймать такую ошибку можно лишь при качественном покрытии тестами или при использовании линтеров с кастомными правилами, настроенными на подобные кейсы.

В итоге давайте взвесим все плюсы и минусы:
Exceptions | Typed errors | |
Плюсы | • Пишем код так, как завещали предки. • В исключениях хранится подробная информация о том, где именно было зафиксировано исключение вплоть до самого основания стека вызовов. • Все фреймворки, библиотеки и другие коллеги-разработчики привыкли к логике бросания исключений и ожидают этого от клиентского кода. | • Метод явно декларирует варианты результатов и мы обязаны их обрабатывать. • Удобный DSL для обработки ошибок - можем проверить, смаппить или сбиндить ошибку. • Видно, какие методы возвращают ошибку среди большого количества вызовов. |
Минусы | • Частое игнорирование проверяемых исключений и опасность забыть, поймать непроверяемые. • Гигантские блоки try/catch прячут реальный метод, откуда бросилось исключение. • Сомнительный перформанс при большом количестве исключений. | • Код становится сложнее. Никаких Either, left/right могло и не быть, а программист обязан подумать еще один раз. • Никто не гарантирует, что фреймворки/зависимости вокруг нас сразу перестанут кидать исключения. Получается, что теперь мы должны поддерживать обе парадигмы обработки ошибок в нашем проекте. • +1 зависимость в проекте. • Неожиданные приколы с фреймворками и библиотеками. Данная практика не настолько распространена, чтобы ее поддерживали все решения. |
Если бы я мог выбирать при старте проекта, какой подход использовать, то я бы воспользовался следующими правилами:
Если проект — классическое приложение на Spring Boot/Data Jpa/Secutrity, в котором нет сложной логики с различными ошибками, появляющимися на том или ином этапе выполнения, то подходит традиционный подход с исключениями. Эти фреймворки ожидают, что мы будем кидать исключения, а логика не так сложна, чтобы контролировать ее с помощью Typed Errors.
Если проект содержит запутанную бизнес-логику с различными вариантами поведения, правилами валидации и важно не падать при любом возможном случае, а всегда гибко обрабатывать ошибки, тогда наш выбор — Typed Errors. Это особенно логично, если мы планируем писать код в функциональном подходе с использованием chained-функций и методов с различными ресиверами.
Понятно, что идеального решения для обработки ошибок не существует — у каждого подхода свои сильные и слабые стороны, и многое зависит от конкретных задач и архитектуры проекта. Поэтому перед тем, как сломя голову внедрять очередную модную библиотеку или верной и правдой служить классическим исключениям, стоит трезво оценить, что именно требуется вашему проекту и команде.
Заключение
Как и у любого решения, у «Исключений» и «Typed Errors» есть свои плюсы и минусы, которые мы как разработчики, должны учитывать. Важно помнить, что не существует серебряной пули: иногда проще использовать проверенные временем «Исключения», а при других условиях избегать неприятных сюрпризов на проде и делать код более предсказуемым для коллег помогает Typed Errors. В конечном счёте, именно умение выбирать соответствующую проекту комбинацию фреймворков, библиотек и подходов — чаще всего отличает профессионального программиста.
Надеюсь, этот разбор поможет вам взглянуть на обработку ошибок свежим взглядом и выбрать путь, который лучше подойдет вашему проекту.
Спасибо за внимание! Если есть вопросы или хочется поделиться своим опытом — пишите в комментариях, обсудим!