И опыт, сын ошибок трудных: обрабатываем ошибки в Spring Boot
Долгое время разрабатывая микросервисы в разных командах, я сталкивался с типовой задачей: созданием максимально информативного ответа на запрос, когда произошла какая-то ошибка. Особенно это актуально для систем с пользовательским фронтендом, большим количеством интеграций или систем, которые представляют свой API как продукт. Во многих случаях это решалось выдачей сообщения «Ошибка системы» с HTTP-кодом 500. Из раза в раз меня не покидало ощущение, что решению этой задачи не уделяется должного внимания и времени. В текущем проекте нам пришлось пройти все круги ада, изменить несколько подходов и реализаций. И здесь я постарался описать, как это было, и сформулировать выводы, которые мы сделали на каждом шаге решения проблемы.
Коротко о системе
Все микросервисы нашей системы унифицированы и построены по классическим канонам Spring Boot. Под капотом мы имеем Java 11/17 и Spring Boot 2.6. Так было до недавнего времени, пока мы не переехали на Spring Boot 3.2. Функциональность в чистом виде можно описать так: к нам приходят запросы с фронтенда или из других сервисов, данные обрабатываются с последующим чтением или записью в БД и синхронными и асинхронными запросами в другие системы. На уровне архитектуры кода есть три слоя:
View — обработчики запросов, как правило это RestController.
Service — обработчики бизнес‑логики, компоненты, мапперы.
DAO — слой доступа к данным. Условно примем, что это классы для общения с БД или клиенты к другим системам для обмена сообщениями.
Архитектура приложения
Так какие блоки кода могут инициировать ошибки, о которых мы будем говорить? Кроме вышеописанных слоёв, которые контролируются с помощью Spring, ошибки могут возникать в подключаемых стартерах, утилитарных классах со статическими методами, мапперах, прочих вспомогательных классах (вроде аспектов или HTTP-фильтров) и в самой Java Core.
Инструменты
Для обработки ошибок и составления корректного ответа не будем изобретать велосипед и остановимся на стандартных инструментах. Коротко их рассмотрим.
1. Исключения
В Java все обрабатываемые ошибки описываются классом Exception
. Объекты этого класса immutable
, поэтому все основные значения задаются через конструктор. Нам достаточно рассмотреть тот, что принимает аргумент message
(сообщение) и cause
(другое исключение, описывающее причину):
package java.lang;
public class Exception extends Throwable {
public Exception(String message, Throwable cause) {
super(message, cause);
}
}
Сразу стоит вспомнить про иерархию исключений.
Иерархия исключений Java
Иерархия обширна и включает в себя огромное количество исключений из ядра Java, Spring, стартеров, библиотек и прочих компонентов. Также ничто не мешает описывать собственные исключения, используя наследование и переопределяя необходимые конструкторы:
public class SystemException extends RuntimeException {
private final String systemId;
/*...*/
public Exception(String message, Throwable cause, String systemId) {
super(message, cause);
this.systemId = systemId;
}
/*...*/
}
Исключения в приложении могут инициироваться в явном и неявном виде. В первом случае в Java используется оператор throw
.
public class MyService {
public void doThrowArifmethicException(int a, int b){
return a/b
}
public void doThrowRuntimeException() {
throw new RuntimeException("Something wrong");
}
}
Для обработки ошибок в коде используется блок try-catch
:
package com.company;
public class MyService {
public void doSomething() {
try {
//call code with execution error
doSomethingWithError();
} catch (CustomException e) {
handleCustomException(e);
} catch (NullPointerException e) {
handleNullPointerException(e);
} catch (Exception e) {
throw new MyException(e);
}
}
}
В одном блоке мы можем обработать несколько исключений по типам от нижестоящего к вышестоящему по иерархии. При обработке ошибок в блоке catch
может возникнуть ошибка, или мы можем инициировать в нём новое исключение.
Говоря об обработке исключений, стоит вспомнить о процессе «всплытия» ошибок: если исключение было выкинуто и не перехвачено, то оно перейдёт в вышестоящий метод, который прервётся на строчке вызова метода, инициировавшего исключение, и так произойдёт до блока try-catch
или самого верхнего класса по иерархии вызовов.
Всплытие исключений Java
2. Spring @ControllerAdvice
Самым главным инструментом для создания ответа об ошибках системы на запрос в Spring используется класс, помеченный аннотацией @ControllerAdvice
.
@ControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
//...
@ExceptionHandler
public ResponseEntity> handleNpeException(NullPointerException e, WebRequest webRequest){
return new ResponseEntity<>("NpeException handled", HttpStatus.BAD_REQUEST);
}
@ExceptionHandler
public ResponseEntity> handleException(Exception e, WebRequest webRequest){
return new ResponseEntity<>("Exception handled", HttpStatus.INTERNAL_SERVER_ERROR);
}
//...
}
В коде каждого микросервиса мы можем создать обработчик с нужными типами исключений. Для обработки типовых ошибок стоит унаследовать его от готовой реализации ResponseEntityExceptionHandler
. Каждый из описанных методов в @ControllerAdvice
принимает свой тип исключения и объект request
, и возвращает объект, описывающий ошибку, который будет преобразован в JSON ответа. ExceptionHandler
срабатывает на уровне аспектов, когда из контроллера «всплывает» ошибка:
Spring @ControllerAdvice
При поступлении исключения в ExceptionHandler
будет вызван соответствующий этому исключению метод, а в случае его отсутствия — метод с вышестоящим по иерархии типом исключения. Чтобы ExceptionHandle
гарантированно выдал нужный ответ, следует иметь главный обработчик типа Exception
.
3. Spring AOP
Spring AOP — очень мощный инструмент, который позволяет избавиться от шаблонного кода. Нам потребуется рассмотреть создание аспектов с помощью собственной аннотации в случаях, когда метод выбрасывает исключение. Для начала создадим аннотацию:
@Target(value = { ElementType.METHOD})
@Retention(value = RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
String value() default "";
}
Далее напишем простенький обработчик, который в случае возникновения ошибки создаст RuntimeException
с сообщением, указанным в поле value
аннотации и возникшим исключением в поле cause
.
@Aspect
@Component
public class MyAspect {
@AfterThrowing(value = "@annotation(myAnn)", throwing = "ex")
public void handleMethodException(MyAnnotation myAnn, Throwable ex) throws Throwable {
//здесь мы можем что-то сделать с объектом аннотации и исключения
//в конце кидаем исключение, которое прокси вернет
//как результат работы метода в случае ошибки
throw new RuntimeException(myAnn.value(), ex);
}
}
Теперь проксирование позволит нам использовать нашу аннотацию в объектах классов, созданных с помощью Spring:
@Component
public class MyService{
@MyAnnotation("Message from annotation")
public void someMethod(){ ... }
}
Мы рассмотрели инструментарий, которым будем пользоваться, а теперь перейдём к реализации проекта.
Шаг 0. Используем Spring «из коробки»
Как и практически любую современную систему, нашу создавали в сжатые сроки и инкрементально. На этапе MVP обработке ошибок уделяли минимум времени и внимания, основным требованием было выдавать текст ошибки в своём формате и stacktrace в журнал. Опустив лишние поля, ответ в случае ошибки выглядел так:
{
"message": "Exception message"
}
Естественно, мы определили DTO ответа и @ControllerAdvice
, который формировал ответы в нужном нам формате:
//DTO of error response
public class ErrorResponse {
private final String message;
}
@ControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler
public ResponseEntity> exception(Exception e, WebRequest webRequest){
return new ResponseEntity<>(
new ErrorResponse("Ошибка сервиса"), HttpStatus.INTERNAL_SERVER_ERROR
);
}
@ExceptionHandler
public ResponseEntity> mappingException(MappingException e, WebRequest webRequest){
return new ResponseEntity<>(
new ErrorResponse(e.getMessage()), HttpStatus.BAD_REQUEST
);
}
}
В местах, где это требовалось, мы писали собственные типы исключений, просто унаследовав их от RuntimeException
:
public class MappingException implements RuntimeException {
public MappingException(String message){
super(message);
}
}
Такой подход привёл к значительному увеличению классов, описывающих ошибки, в совсем простых приложениях. Это немного усложняло код, да и по смыслу было не всегда понятно, какой тип исключений можно безболезненно переиспользовать. В одном из сервисов мы попытались решить эту проблему с помощью вложенных классов:
public class OperationException implements RuntimeException {
public static class FirstOperationException extends OperationException { ... }
public static class SecondOperationException extends OperationException { ... }
}
Другой проблемой оказалось частое использование RuntimeException
, просто потому что это быстрее и удобнее. Тем не менее, разбирать постоянные ошибки в логах с типом RuntimeException
, по ощущениям, — как гадание на кофейной гуще. Кроме того, и сами сообщения об ошибках оставляли желать лучшего по информативности, а иногда такое сообщение могло быть пустым (поле message
равнялось null
). Это заставило нас в @ControllerAdvice
выводить статичный текст в случае null-значений.
Самый значительный недостаток такой реализации заключался в невозможности отобразить во фронтенде текст об ошибке для пользователя. Так, при получении ответа, отличного от 200, выводилось однотипное сообщение «Произошла ошибка системы», без каких-либо подробностей.
Шаг 1. Просто добавим код ошибки и текст сообщения
После окончания MVP и начала эксплуатации системы появилось требование детализировать сообщение, которое выдаётся при ошибочном запросе. Немного дополнили API и поменяли логику заполнения: поле message
содержит текст на русском языке для представления на фронтенде, поле code
содержит код ошибки (тоже для вывода на фронт), поле description
содержит message
из исключения:
{
"code": "000000",
"message": "Текст ошибки на русском языке",
"description": "Exception message"
}
Класс, описывающий ответ, дополнили новыми полями и модифицировали @ControllerAdvice
:
//DTO of error response
public class ErrorResponse {
private final String code;
private final String message;
private final String description;
}
@ControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler
public ResponseEntity> mappingException(MappingException e, WebRequest webRequest){
return new ResponseEntity<>(
new ErrorResponse("000100", "Ошибка маппинга", e.getMessage()),
HttpStatus.BAD_REQUEST
);
}
//another handlers
}
Таким образом у нас получилось сделать уникальный код ошибки для каждого типа исключений. Коды ошибок, описанные в одном классе, было легко определить и задокументировать. Главным недостатком такого подхода стала невозможность определить точное место возникновения ошибки, так как одно и то же исключение могло возникнуть в совершенно разных местах (например, RestClientException
возникает в каждом REST-клиенте). Да и обработчики не могли покрыть все типы исключений, поэтому основным оставался обработчик типа Exception
.
Шаг 2. Очевидное и невероятное
Когда у пользователя возникает та или иная ошибка в системе, он делает скриншот экрана и отправляет его в поддержку. Для быстрого и качественного обслуживания пользователей возникло новое требование по созданию кода ошибки на каждую операцию и тип ошибки. Для решения этой задачи создали свой тип исключения, содержащий код ошибки:
public class SystemException implements RuntimeException{
private final String code;
public SystemException(String message, Throwable cause, String code) { /*...*/ }
}
Далее, в нужных местах мы будем выбрасывать это исключение или его потомков. По сути, мы просто «оборачиваем» возникшее исключение в SystemException
, записывая его в cause
. Маппинг с ответным сообщение происходит так:
{
"code": "SystemException.code",
"message": "SystemException.message",
"description": "SystemException.cause.message"
}
Такое решение кажется простым и рабочим, но на самом деле мы сталкиваемся со множеством проблем:
Для описания ошибок нужно, по возможности, использовать только
SystemException
или его потомков, что сужает используемую иерархию исключений.Встречаются случаи, когда мы обрабатываем исключения, отличные от
SystemException
, что сохраняет прошлые обработчики и неопределённость о месте возникновения ошибки.Сильно усложняется логика в классах проекта. При написании кода нужно думать больше о блоках
try‑catch
, чем о бизнес‑логике. Пример конструкций, которые встречаются повсеместно://Пример плохого кода № 1 public class MyClient { public Response call(){ try{ //call restTemplate.execute } catch (RestClientResponseException e){ throw new IntegrationException("r111500", "Ошибка ответа", e); } catch (RestClientException e){ throw new SystemException("r111501", e); } catch (Exception e){ throw new RuntimeException("Ошибка интеграции с системой"); } } } //Пример плохого кода № 2 public class MyService{ public void doOperation(){ try{ //Code of business logic if(badCondition){ throw new SystemException("r235900", "Операцию не выполняем"); } //Code of buiness logic } catch (SystemException e){ throw e; } catch (Exception e){ throw new SystemException("r235901", "Ошибка операции", e); } } }
Поначалу пытались систематизировать и описать в документации коды ошибок, но не получилось. По факту, при написании кода разработчик придумывал число от 000 001 до 999 999 и вписывал его в код ошибки. Естественно, невозможно сразу ответить на вопрос о месте её возникновения без поиска по тексту проекта.
Описания ошибок на русском языке равномерно размазываются по коду проекта, что не добавляет удобства при поддержке. В текстах описаний иногда встречаются стилистические или грамматические ошибки, бизнес требует срочно их устранить, выпустив новую версию приложения.
Логика
@ControllerAdvice
сильно усложняется. Возникшие исключения, которые мы сложили в cause дляSystemException
, в некоторых случаях требуют дополнительной обработки. И вот таким образом у нас появляются блокиinstanceof
и прочие «чудеса»:@ControllerAdvice public class ControllerExceptionHandler extends ResponseEntityExceptionHandler { //... @ExceptionHandler public ResponseEntity> systemException(SystemException e, WebRequest webRequest){ var code = isNull(e.getCode()) || e.getCode().isBlank() ? "r000200" : e.getCode(); var message = isNull(e.getMessage()) || e.getMessage().isBlank() ? "Ошибка операции" : e.getMessage(); String cause = ""; if(e.getCause() != null && e.getCause().getMessage() != null){ cause = e.getCause().getMessage(); } if(e.getCause() instanceof ExecutionException ee){ cause = e.getCause().getMessage(); } if(e.getCause() instanceof RestClientResposeException ex){ /*...*/ } /*...*/ } @ExceptionHandler public ResponseEntity> extendedSystemException( ExtendedSystemException e, WebRequest webRequest ){ //another handler for child SystemException } @ExceptionHandler public ResponseEntity> handleException(Exception e, WebRequest webRequest){ ErrorResponse errorResponse = new ErrorResponse("r000000", "Ошибка сервиса", e.getMessage()); return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); } //... }
При создании
SystemException
нам ничто (кроме здравого смысла) не мешает записатьnull
в поляcode
,message
иcause
. Теперь иногда мы начинаем получать ошибки при обработке ошибок.Неудобство при чтении
stacktrace
. На первом месте теперь наша ошибка, а до реальной трассировки проблемы нужно листать. Для сотрудников поддержки, которые читают журналы, это критичное неудобство.
Шаг 3. Попытка спасти код
Глядя на монструозность повсеместных блоков try-catch
, возникла идея сделать рефакторинг и спрятать весь шаблонный код «обёртки» исключений в аспектах. Задача не сложная, для этого сделали следующее:
Написали аннотацию, на которую будет срабатывать аспект.
@Target(value = { ElementType.METHOD}) @Retention(value = RetentionPolicy.RUNTIME) public @interface ErrorCode { //код ошибки String value(); //сообщение об ошибке String message() default ""; //тип исключения, который будет выброшен Class extends ErrorCodeException> exception() default SystemException.class; //исключения, при которых сработает аспект //по умолчанию обрабатываем все Class extends Throwable>[] on() default {}; //список дополнительных правил Code[] codes() default {}; }
Для установки дополнительных правил обработки потребовалась ещё одна вложенная аннотация:
@Target(value = { }) @Retention(value = RetentionPolicy.RUNTIME) public @interface Code { //код ошибки String value(); //сообщение об ошибке String message() default ""; //тип исключения, который будет выброшен Class extends ErrorCodeException> exception() default SystemException.class; //исключения, которые обработает эта аннотация Class extends Throwable>[] on(); }
Написали аспект для обработки аннотаций со следующей логикой:
@Aspect @Component public class MyAspect { @AfterThrowing(value = "@annotation(errorCode)", throwing = "ex") public void handleExeption(ErrorCode errorCode, Throwable ex) throws Throwable { try { /* Код этого аспекта принимает аннотацию метода и выброшенное исключение. На выходе метод должен выбросить новое исключение. На всякий случай обернем весь код в блок try-catch Проверяем тип иключения ex: - Если типа SystemException или его потомков, выбрасываем без обработки - Если тип совпадает в одним из типов описанных в поле on во вложенных аннотациях @Code, то данные берем из этой аннотации. - Последним условием проверяем что тип исключения входит в список on аннотации @ErrorCode или спискок on пустой Если проверки прошли успешно то создаем через рефлексию и выбрасываем SystemException или его потомка указанного в поле exception аннотации. При этом code заполняем из поля value, message из соответствующего поля аннотации, исключение ex записываем в cause. Если проверка не успешна, то выбрасывам исключение метода без обработки. */ } catch (Exception e){ throw ex } } }
Пометили все наши методы аннотацией
@ErrorCode
:@Service public class MyService { @ErrorCode(value = "r100500", message="Ошибка операции 1", codes = { @Code(value="r100501", message="Нет данных", on = NullPointerException.class), @Code(value="r100502", message="Ошибка расчета", on = CustomException.class) }) public void doSomething1() { /*...*/ } @ErrorCode(value = "r100600", message="Ошибка операции 2") public void doSomething2() { /*...*/ } }
В результате рефакторинга ситуация с обработкой ошибок стала менее прозрачной, но более контролируемой. Мы избавились от блоков try-catch
и получили заполненные поля с кодом и текстом ошибки. Код выглядит гораздо лучше, но работает только в контексте Spring Boot и в том случае, если исключение было обработано аспектом.
Шаг 4. Переделываем всё
Все предыдущие шаги не решили наши проблемы, а порой добавили новые. Собрав все пожелания от заказчиков, поддержки, тестировщиков и прочих причастных, мы решили переосмыслить дизайн решения. Требования получились следующие:
Нужно всё упростить. Сделать обработку ошибок максимально удобной, перестать «выдумывать» коды ошибок, перейти к типизации ошибок через стандартные механизмы Spring и избавиться от
instanceof
в@ControllerAdvice
.Код ошибки должен быть динамическим и информативным. Взглянув на него, каждый причастный должен понимать, что именно произошло в системе: отвалилась БД или интеграция, получен неожиданный ответ от другой системы, ошибка валидации или же это
NullPointer
. На каждую из таких ошибок необходима своя реакция: либо срочно что‑то исправлять, либо писать в другие команды, либо просто подождать, пока поднимется инфраструктура. Это требование важно как для промышленной среды, так и для сред интеграционного и функционального тестирования.Нужно каким‑то образом вынести тексты ошибок в отдельное место и иметь возможность их править. В идеале, без изменения версии продукта.
Нужна большая гибкость и возможность дополнять ответ новыми полями (например прокинуть ID системы, интеграция с которой отвалилась, или код SQL‑ошибки).
Stacktrace должен быть удобочитаемым.
Было очевидно, что практика «обёртки» исключений нам не подходит. А значит, будем записывать нужную информацию о бизнес-ошибке в само всплывающее исключение.
Для начала создали список с типами ошибок нашей системы:
public enum ErrorType { DEFAULT, SYSTEM, SERVICE, REPOSITORY, COMPONENT, VIEW, AUTHORIZATION, INPUT_REQUEST, INPUT_REQUEST_VALIDATION, OUTPUT_RESPONSE, OUTPUT_RESPONSE_VALIDATION, MAPPING, INTEGRATION_SYNC, INTEGRATION_ASYNC, VALIDATION, EXTERNAL_SYSTEM; }
Создали новые аннотации для нашего аспекта:
@Target(value = { ElementType.METHOD}) @Retention(value = RetentionPolicy.RUNTIME) public @interface ErrorDefinition { //Код ошибки, никогда не может быть пустым String value(); //Тип ошибки, описан enum из п.1 ErrorType type() default ErrorType.DEFAULT; //Список аннотаций ключ-значение для детализации ошибки Detail[] details() default {}; } @Target(value = { }) @Retention(value = RetentionPolicy.RUNTIME) public @interface Detail { //наименование параметра String key(); //значение параметра String value(); }
Создали исключение, которое будет хранить нужную информацию:
public class ErrorDefinitionException extends RuntimeException { private final String errorCode; private final ErrorType type; private final Map
details; public ErrorDefinitionException( String message, String errorCode, ErrorType type, Map details ) { ... } @Override public String getMessage(){ ... }
Написали аспект для обработки исключений методов. Принцип его работы заключается в том, чтобы добавить единственный
ErrorDefinitionException
в списокsuppressed
возникшего в методе исключения.ErrorDefinitionException
заполняется в методеbuildException
данными аннотации и информацией из объектаjoinPoint
. Если у нас не указанErrorType
в аннотации, то можем прочитать аннотации класса (@Service
,@Repository
,@Component
и т. д.) и автоматически присвоить тип (слой) ошибки.@Aspect @Component public class ErrorDefinitionAspect { @AfterThrowing(value = "@annotation(errorDefinition)", throwing = "ex") public void handleMethodException( JoinPoint joinPoint, ErrorDefinition errorDefinition, Throwable ex ) throws Throwable { val errorDefinitionException = findErrorDefinitionException(ex); if(isNull(errorDefinitionException)){ errorDefinitionException = buildException(joinPoint, errorDefinition); ex.addSuppressed(errorDefinitionException); } throw ex; } }
Для хранения текстов ошибок создали файл
error.property
в ресурсах проекта:r0000=Произошла внутренняя ошибка сервиса r0001=Ошибка при обработке входящего запроса #.............. r0011=Не поддерживаемый метод для запрошенного URL r0012=Указанный mediaType не поддерживается для запроса #..............
Создали
MessageSource bean
для чтенияerror.properties
:@Bean @ConditionalOnMissingBean public MessageSource errorsMessageSource(){ val messageSource = new ReloadableResourceBundleMessageSource(); messageSource.setBasenames("classpath:errors"); messageSource.setUseCodeAsDefaultMessage(true); messageSource.setDefaultLocale(Locale.getDefault()); messageSource.setDefaultEncoding("UTF-8"); return messageSource; }
Осталось только начать использовать нашу аннотацию в классах Spring, чтобы информация записалась в возникающие исключения:
@Service
public class MyService {
@ErrorDefinition("r1566")
public void doFirst() { /*...*/ }
@ErrorDefinition(value = "r1566", type = INTEGRATION_SYNC, details = {
@Detail(key = SYSTEM_ID, value = "SYS123"),
@Detail(key = "someKey", value = "someValue")
})
public Object doSecond() { /*...*/ }
}
Мы снова пришли к принципам обработки исключений по типу, заложенных в Spring @ControllerAdvice
. Поскольку обработка нашего запроса так или иначе проходит через слои Spring, к любому возникшему исключению в процессе «всплытия» будет добавлена информация о типе и коде ошибки:
Обогащение исключений аспектом
Теперь задача свелась к рекурсивному поиску ErrorDefinitionException
в исключении suppressed
и вытаскиванию из него нужной информации для создания ErrorResponse
. По значению errorCode
можно получить текст ошибки на русском языке, а если мы забыли описать его в .properties
, то задать стандартный для нашего типа исключений.
Нет смысла долго описывать нашу логику для построения ответа, у каждого она будет своя. Если коротко, то мы создали класс-обёртку над исключением:
@Data
@Accessors(fluent = true, chain = true)
@RequiredArgsConstructor
public class ErrorMessage {
private final Exception ex;
private final HttpStatus status;
private final WebRequest request;
//задать иную логику формирования errorCode в ответе
private Supplier errorCode;
//будет использовать код сообщения, если не найден
private Supplier defaultMessageCode;
//извлечь cause из сообщения
private Supplier cause;
/*...*/
}
Объекты данного класса подаются в фабрику, которая по определенной логике строит ответное сообщение. В нашем случае код @ControllerAdvice
выглядит следующим образом:
@ControllerAdvice
public class ControllerExceptionHandler extends ResponseEntityExceptionHandler {
@Autowired
private ResponseEntityFactory responseEntityFactory;
//...
@ExceptionHandler
public ResponseEntity executionException(
ExcecutionException e,
WebRequest request
) {
return responseEntityFactory.create(
new ErrorMessage(e, BAD_REQUEST, request)
.defaultMessage(() -> "r0000")
.defaultType(() -> ErrorDefinitionException.Type.SERVICE)
.cause(()-> ex.getCause())
);
}
//...
}
Естественно, можно обрабатывать и сам ErrorDefinitionException
, а для добавления в suppressed
использовать static-метод в блоке try-catch
вручную, но нам на практике это не понадобилось.
Можно сказать, что этот подход решил все наши проблемы и дал невероятную гибкость. Бонусом мы получили возможность интернационализации сообщений об ошибках. Ещё одним бонусом стала потенциальная возможность вынести все сообщения и коды ошибок в SpringCloud, что позволит менять тексты «на лету».
Заключение
Я постарался описать наш длинный путь к осознанию такой, на первый взгляд, несущественной проблемы, как обработка ошибок. Её сложность неочевидна для бизнеса, зачастую не закладывается в бэклог. Этой проблеме всегда уделяется мало внимания до тех пор, пока поддержка и эксплуатация системы не станет невероятно сложной. В статье не упомянуто про Spring Boot 3.X, в котором @ControllerAdvice
претерпел изменения (в нём теперь по умолчанию есть MessageSource
, который по типу ошибки может подтянуть сообщения), так как он сложнее для описания, но работает на тех же принципах.
Посмотреть запись митапа, на котором я рассказал о нашем опыте с обработкой ошибок, можно здесь.