1. try/catch в контроллере: удобство и ловушка
Когда вы только начинаете писать REST API на Spring MVC, идея «обработаю ошибку прямо тут же, в методе контроллера» выглядит очень естественной. Это как держать аптечку рядом с рабочим столом: случилось что-то неприятное — быстро среагировали, выдали красивый 404, сформировали ProblemDetail, клиент доволен. Но в API это работает ровно до момента, пока у вас не станет больше одного endpoint’а и больше одного вида ошибок.
Проблема в том, что контроллер по своей природе — это граница: он уже делает много «транспортных» вещей (path/query/body binding, статус, заголовки, response body). Если туда же засунуть ещё и полноценную сборку ошибок, то контроллер превращается в универсальный «комбайн», который и запросы принимает, и бизнес-смысл понимает, и формат ошибок лепит, и статус выбирает. В итоге растёт код, падает читаемость, а единый контракт ошибок начинает расползаться по проекту.
Посмотрим на типичный «первый рабочий вариант», который почти всегда появляется в учебных проектах. Он не плохой «потому что так нельзя», он плохой потому что не масштабируется.
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<?> getTask(@PathVariable String taskId) {
try {
// Happy-path: просто возвращаем данные задачи
return ResponseEntity.ok(taskService.getById(taskId));
} catch (TaskNotFoundException ex) {
// Error-path: контроллер сам решает, какой статус и какое тело ошибки вернуть
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
// Вручную заполняем поля контракта ошибки (и так придётся делать в каждом endpoint’е)
problem.setTitle("Task not found");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
}
Код «правильный» в том смысле, что он действительно вернёт 404 и действительно вернёт ProblemDetail. Но обратите внимание: мы уже сделали контроллер ответственным за две задачи сразу — happy-path и error-path. И пока это один метод, это терпимо. А теперь представьте, что таких методов станет двадцать, и у каждого будут свои нюансы.
2. «Толстый» контроллер: смешение ответственности
Контроллер в нашем курсе задуман как тонкий слой. Он описывает HTTP-контракт: «какой URL», «какой метод», «какой вход», «какой выход». Он должен напоминать переводчика, который аккуратно переводит с языка HTTP на язык приложения (вызов сервиса) и обратно. Как только контроллер начинает «лечить» все ошибки, он перестаёт быть переводчиком и становится ещё и врачом, ещё и судьёй, ещё и нотариусом, который заверяет правильность каждого статуса.
Смешение ответственности выглядит так: вы хотите просто прочитать метод контроллера и понять, что он делает. Но вместо этого читаете половину страницы с обработкой исключений. Это особенно больно в endpoints вроде DELETE, где happy-path занимает две строчки, а catch — десять. В какой-то момент вы начинаете думать «ну ладно, я скопирую этот catch», и именно так рождается копипаста.
Сравните два варианта. Первый — контроллер как «комбайн»:
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
@DeleteMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<?> delete(@PathVariable String taskId) {
try {
// Happy-path: удаляем и возвращаем 204
taskService.delete(taskId);
return ResponseEntity.noContent().build();
} catch (TaskNotFoundException ex) {
// Error-path: снова вручную собираем ProblemDetail
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Task not found");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
}
И второй — контроллер как «тонкая граница» (пока без обсуждения как именно будет обработано исключение, это следующая лекция):
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseStatus;
import static org.springframework.http.HttpStatus.NO_CONTENT;
@DeleteMapping("/api/v1/tasks/{taskId}")
@ResponseStatus(NO_CONTENT) // Контракт успешного ответа: 204 без тела
public void delete(@PathVariable String taskId) {
// Контроллер не собирает ошибки: просто делегирует в сервис
taskService.delete(taskId);
}
Во втором варианте мы сразу видим смысл endpoint’а: удалить задачу и вернуть 204. Ошибка «задача не найдена» не описана здесь, но это нормально: контроллер не обязан быть энциклопедией всех бедствий. Его задача — «что делаем, когда всё хорошо». А «что делаем, когда всё плохо» должно решаться единым образом, иначе у нас не получится единый error contract.
И да, в реальности у начинающих чаще получается так: часть методов «возвращает void с @ResponseStatus», часть делает ResponseEntity, часть ловит исключения, часть не ловит. В итоге проект живёт по принципу «как получилось» — и это очень быстро становится проблемой.
3. Копипаста в try/catch: рост вместе с endpoint’ами
Сначала у вас один endpoint, и вы честно написали catch (TaskNotFoundException ex). Потом добавили второй endpoint и сделали «копировать-вставить, но поменять путь». Потом третий. Потом внезапно вам нужно поменять текст title или добавить новое поле в ProblemDetail, или изменить detail, или унифицировать формулировку. И тут вы обнаруживаете, что у вас таких catch-блоков уже десять.
Самая неприятная часть в том, что копипаста в error handling почти всегда выглядит «логично» в момент написания. Вы не ощущаете, что сделали что-то плохое, потому что код компилируется, тест в Postman зелёный, а клиент получает JSON. Но архитектурно вы подписались на пожизненную рассрочку: любое улучшение ошибки теперь требует пройтись по куче мест.
Представим, что вы хотите сделать единый стиль, например всегда писать title="Resource not found", а в detail добавлять конкретику. Если ошибки формируются прямо в контроллере, вы будете выискивать эти строки по проекту. А если завтра появится не только TaskNotFoundException, но ещё CommentNotFoundException и AttachmentNotFoundException, то начнётся ещё более весёлая игра «добавь catch везде».
Чтобы почувствовать проблему, достаточно увидеть, что один и тот же паттерн повторяется:
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
private ResponseEntity<ProblemDetail> notFound(String detail) {
// Локальный helper — первый сигнал, что логика ошибки повторяется и просится в одно место
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, detail);
pd.setTitle("Not found"); // Ещё одно место, где легко «случайно» поменять формулировку
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
Этот helper уже намекает: «похоже, это должно быть где-то в одном месте». И это правда. Но если вы держите всё в контроллерах, вы либо начнёте плодить такие helper’ы в каждом контроллере, либо начнёт делать общий утилитный класс, который внезапно станет зависеть от HTTP и начнёт использоваться «везде», включая сервисы. А это следующий уровень архитектурного квеста.
4. Как расползается error contract: одна ошибка — разные ответы
Единый error contract — это договор между вашим API и клиентом. Клиент хочет понимать: если задача не найдена, то это всегда 404, всегда application/problem+json, всегда определённый набор полей, и желательно стабильные title/detail (или хотя бы стабильный application-specific code, который мы будем развивать позже по плану курса).
Локальный try/catch этот договор разрушает не потому, что «аннотации круче», а потому что люди разные. Вы (или ваш коллега) в одном контроллере напишете так:
{
"title": "Task not found",
"status": 404,
"detail": "Task '123' not found"
}
А в другом так:
{
"title": "Not Found",
"status": 404,
"detail": "No task"
}
Формально оба ответа «верные»: и там 404, и там JSON, и там что-то написано. Но для клиента это уже два разных мира. Если фронтенд хочет показать пользователю красивое сообщение, он начинает гадать, какой title придёт. Если другой сервис хочет обработать ошибку автоматически, он вынужден парсить текст (а это почти всегда плохая идея, потому что текст — для людей, а не для кода).
Самое коварное здесь то, что рассинхрон появляется постепенно. Сегодня вы сделали Task not found. Завтра кто-то сделал Not Found. Послезавтра вы решили, что Task not found — слишком узко, и сделали Resource not found. И вот у вас уже три варианта одного смысла.
Локальные try/catch по контроллерам почти гарантируют, что единый контракт будет жить «в голове», а не в коде. А в программировании то, что живёт «в голове», имеет привычку исчезать при первом же отпуске или дедлайне.
5. try/catch не ловит ошибки до контроллера
Есть ещё одна причина, почему «ловить всё в контроллере» — технически нерабочая стратегия даже если вы очень дисциплинированный человек и обещаете себе «я буду писать одинаково». В Spring MVC значительная часть ошибок происходит раньше, чем управление попадёт в ваш метод.
Например, malformed JSON. Клиент прислал сломанный JSON, или прислал строку там, где ожидается число, или прислал неправильный формат даты. Это всё происходит на этапе чтения request body через конвертеры. И если ошибка случилась там, до вашего метода контроллера дело просто не дойдёт.
Сценарий выглядит примерно так:
sequenceDiagram
participant Client as HTTP-клиент
participant MVC as "Spring MVC (DispatcherServlet)"
participant Conv as "Body conversion (Jackson)"
participant Ctrl as Метод контроллера
Client->>MVC: POST /api/v1/tasks + JSON body
MVC->>Conv: прочитать и распарсить body
Conv-->>MVC: "ошибка (например, JSON сломан)"
Note over Ctrl: метод контроллера не вызывается
MVC-->>Client: 400 (ошибка на уровне web-layer)
То есть вы можете написать хоть три catch в контроллере — они не сработают, потому что метод просто не начался.
Это очень важно для нашего курса, потому что мы уже обсуждали: malformed JSON — не бизнес-ошибка, не «не найдено», и даже не validation в привычном смысле. Это проблема чтения запроса. И если у вас архитектура «ошибки ловим в контроллерах», то вы всё равно столкнётесь с ситуациями, где вам нужен механизм выше уровнем, чем отдельный метод.
Spring MVC как раз поддерживает ProblemDetails (application/problem+json) на уровне фреймворка и включает его настройкой spring.mvc.problemdetails.enabled=true. Это ещё один сигнал: обработка ошибок — это не «частная проблема одного endpoint’а», это поперечная задача всего web-layer.
6. Архитектурная цель: happy-path в контроллерах
Если описать желаемую картину простыми словами, то мы хотим добиться такого эффекта: открываешь любой контроллер — и там читается «что делает endpoint». А если хочешь понять, «как API отвечает при ошибках» — идёшь в одно место и видишь правила.
Это не потому, что мы фанаты «всё централизовать». Это потому, что ошибки — это тоже контракт. И если контракт размазан по 30 методам, он перестаёт быть контрактом и становится «сборником местных обычаев».
Давайте зафиксируем роли в виде маленькой таблицы. Она не про «единственно правильную архитектуру на все времена», а про дисциплину именно нашего курса и нашего проекта Task Tracker API.
| Слой | Что делает в happy-path | Что делает при ошибке |
|---|---|---|
| Controller | Принять HTTP-вход, вызвать сервис, вернуть DTO/статус | Не «лепит JSON ошибки» вручную |
| Service | Выполнить прикладную логику | Сообщает о проблеме через исключение |
| Error handling layer | Не участвует в happy-path | Переводит исключение в ProblemDetail и HTTP-ответ |
В такой схеме сервисный слой остаётся чистым: он не знает про ResponseEntity, не знает про HTTP-статусы, и не превращается в «полу-контроллер». А контроллеры остаются короткими и предсказуемыми.
Именно поэтому локальный try/catch в контроллере — плохая идея: он не просто «дублирует код». Он ломает границу слоёв и мешает сделать один, стабильный, единый контракт ошибок.
7. Рефакторинг: убираем try/catch
Чтобы это не осталось разговором «в теории так лучше», давайте сделаем очень маленький, почти косметический, но принципиальный шаг в стиле кода.
Было (локальный try/catch, контроллер знает как устроен ProblemDetail):
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<?> getById(@PathVariable String taskId) {
try {
// Контроллер возвращает успешный ответ
return ResponseEntity.ok(taskService.getById(taskId));
} catch (TaskNotFoundException ex) {
// Контроллер сам формирует error contract (и будет делать это во многих местах)
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Task not found");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
}
Стало (контроллер описывает контракт успешного ответа):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public TaskDetailsResponse getById(@PathVariable String taskId) {
// Контроллер максимально «тонкий»: только HTTP-вход и делегирование в сервис
return taskService.getById(taskId);
}
И теперь сервис, если задача не найдена, просто выбрасывает доменное исключение (это нормальный способ «сообщить наверх», что сценарий не может продолжаться):
public TaskDetailsResponse getById(String taskId) {
return repository.findById(taskId)
// Happy-path: нашли задачу — маппим в DTO
.map(taskMapper::toDetailsResponse)
// Error-path: не нашли — поднимаем доменную ошибку наверх
.orElseThrow(() -> new TaskNotFoundException(taskId));
}
Важный момент: мы пока не обсуждаем, каким именно способом это исключение превратится в 404 и ProblemDetail. Это специально отложено на следующие лекции дня, потому что там мы будем строить единый слой обработки ошибок и обсуждать механизмы Spring MVC для этого. Сегодня нам нужно почувствовать архитектурную мысль: контроллеры не должны жить в режиме «на каждый endpoint — свой мини-центр обработки катастроф».
8. Типичные ошибки при try/catch в контроллерах
Ошибка №1: ловить Exception и возвращать «что-нибудь» (обычно 500) прямо в контроллере.
Такое решение кажется удобным: «ну если что-то сломалось — вернём internal error». Но вы теряете точность поведения API. Во-первых, некоторые ошибки должны быть 400 или 404, а вы превращаете их в 500, и клиент начинает думать, что сервер “упал”, хотя он просто получил плохой ввод. Во-вторых, как только этот паттерн появляется в одном месте, он начинает копироваться, и проект превращается в набор endpoint’ов с разными «универсальными» обработчиками.
Ошибка №2: копировать сборку ProblemDetail по проекту и надеяться “я везде одинаково вставлю”.
На практике одинаково не получается. Где-то забудут выставить title, где-то поставят другой текст, где-то поменяют detail, где-то вернут ResponseEntity<?>, а где-то — ResponseEntity<ProblemDetail>. В итоге клиенту приходится либо мириться с этим, либо писать костыли. Самое неприятное, что внешне это выглядит как «мелкие отличия», но именно они и ломают контракт.
Ошибка №3: постепенно протащить HTTP-решения в сервисный слой.
Когда контроллеры обрабатывают ошибки вручную, часто возникает соблазн «а давайте сервис сразу скажет, какой статус вернуть». Это приводит к тому, что сервисный слой начинает зависеть от HttpStatus, ResponseEntity и прочих web-типа. После этого тестировать сервис как бизнес-логику становится сложнее, а архитектура начинает напоминать бутерброд из слоёв, где каждый слой знает про соседей всё.
Ошибка №4: считать, что try/catch в методе контроллера — это “глобальная обработка ошибок”.
Мы уже обсудили, что часть ошибок происходит до входа в метод контроллера: malformed JSON, проблемы десериализации, missing параметры, type mismatch. Локальный try/catch просто физически не может их поймать. В результате у вас всё равно появляется второй механизм обработки ошибок где-то выше, и API начинает отвечать разными форматами в зависимости от того, “успела ли ошибка добежать до метода”.
Ошибка №5: делать обработку ошибок частью основной логики endpoint’а.
Иногда в catch начинают не только формировать ответ, но и “доделывать” бизнес-операцию: что-то откатывать, что-то дописывать, менять состояние. Это превращает обработку ошибок в бизнес-логику, только спрятанную в неожиданный угол. Такой код почти невозможно поддерживать: при изменении сценария вы не помните, что “ещё в catch мы делали вот это”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ