Когда браузер или другой сервис обращается к вашему приложению по HTTP, кто-то должен принять этот запрос, понять, какой код за него отвечает, и вернуть ответ. Этим и занимается Spring MVC. Разберём с нуля: как запрос проходит через приложение, как написать контроллер для REST API, как проверить присланные данные и как аккуратно отдавать ошибки.
Зачем вообще нужен Spring MVC
Представьте, что HTTP-запросы вы обрабатываете вручную. Тогда в каждом методе пришлось бы самому разбирать URL, доставать параметры из строки запроса, читать тело и превращать JSON в объект, а потом обратно объект в JSON. Кода много, и он одинаковый в каждом месте.
Spring MVC берёт эту рутину на себя. Вы пишете обычный метод и помечаете его аннотацией — «этот метод отвечает на GET /orders/5». Всё остальное (разбор URL, чтение тела, преобразование JSON) фреймворк делает сам. MVC расшифровывается как Model-View-Controller, но для REST API нас интересует в основном Controller — класс с методами-обработчиками.
Это синхронный веб-стек: каждый запрос обрабатывается своим потоком от начала до конца. Есть и реактивная альтернатива — WebFlux — но она нужна реже, чем кажется, и для типичного backend-сервиса хватает обычного MVC.
Как запрос доходит до вашего метода
Раньше, без единой точки входа, каждый кусок приложения сам решал, какие запросы он ловит — получалась путаница. В Spring MVC есть один «диспетчер», через который проходят все запросы.
DispatcherServlet — это единственный вход в приложение (его называют front controller, «главный контроллер»). Любой HTTP-запрос сначала попадает в него, а он уже решает, куда направить дальше. Аналогия: ресепшен в большом офисе — посетители идут не сразу к нужному человеку, а сначала к стойке, и она подсказывает, в какой кабинет.
Запрос приходит во встроенный Tomcat, оттуда — в DispatcherServlet, тот подбирает метод контроллера, вызывает его и превращает результат в JSON:
Через DispatcherServlet проходят все запросы: он подбирает обработчик по пути и HTTP-методу. Дальше поля проверяет @Valid — и корректный запрос доходит до метода контроллера, а кривой не доходит вовсе: ответ за него собирает @RestControllerAdvice.
Хорошая новость: регистрировать DispatcherServlet руками не нужно — Spring Boot делает это при старте.
До диспетчера запрос успевает пройти ещё один слой, и знать о нём нужно, потому что именно туда садится половина инфраструктуры. Фильтры (Filter из спецификации сервлетов, в Spring удобнее наследовать OncePerRequestFilter) стоят перед DispatcherServlet и видят вообще все запросы, включая те, для которых обработчика нет. В фильтрах живут Spring Security, запись traceId в MDC, логирование запросов, сжатие, ограничение частоты.
@Component
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
MDC.put("traceId", Optional.ofNullable(req.getHeader("X-Trace-Id")).orElseGet(() -> UUID.randomUUID().toString()));
try {
chain.doFilter(req, res); // без этой строки запрос дальше не пойдёт
} finally {
MDC.clear(); // поток вернётся в пул, чужой traceId в нём не нужен
}
}
}
HandlerInterceptor работает уже внутри диспетчера и потому знает то, чего не знает фильтр: какой именно метод контроллера выбран (HandlerMethod) и чем он кончился. Его берут, когда нужно поведение «для контроллеров», а не «для всех запросов»: проверка своей аннотации на методе, измерение времени обработки, подстановка данных в модель. Порядок такой: фильтры, DispatcherServlet, интерцепторы (preHandle), контроллер, интерцепторы (postHandle, afterCompletion), фильтры на обратном пути. Из этого следует практический вывод: исключение, брошенное в фильтре, до @RestControllerAdvice не дойдёт — тот живёт внутри диспетчера, и ошибку фильтра увидит только контейнер.
Запрос идёт слева направо: фильтры видят его раньше диспетчера, поэтому их ошибка до @RestControllerAdvice не доходит.
REST-контроллер
REST API — это набор адресов, на которые клиент шлёт запросы и получает данные (обычно в JSON). Чтобы метод стал таким обработчиком, его кладут в класс с аннотацией @RestController.
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) {
return orderService.findById(id);
}
@PostMapping
public OrderResponse create(@RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
}
Что здесь происходит:
@RestControllerговорит: это контроллер, и то, что возвращают его методы, нужно отдавать как тело ответа (JSON), а не искать HTML-страницу.@RequestMapping("/orders")на классе — общий префикс адреса. Все методы внутри будут начинаться с/orders.@GetMapping,@PostMapping— на какой HTTP-метод и путь отвечает метод. Есть ещё@PutMapping,@DeleteMapping,@PatchMapping— по одному на каждый вид запроса.
Метод get отвечает на GET /orders/5, метод create — на POST /orders. Возвращённый объект Spring сам превратит в JSON.
Как достать данные из запроса
Данные приходят в запросе тремя разными способами, и для каждого своя аннотация.
// /orders/5 — часть самого адреса
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) { ... }
// /orders?status=NEW&limit=20 — параметры после знака ?
@GetMapping
public List<OrderResponse> list(
@RequestParam(defaultValue = "ANY") String status,
@RequestParam(defaultValue = "10") int limit) { ... }
// тело POST-запроса (JSON) превращается в объект
@PostMapping
public OrderResponse create(@RequestBody CreateOrderRequest request) { ... }
@PathVariable— значение прямо из адреса (5в/orders/5).@RequestParam— параметр из строки запроса (status=NEWв/orders?status=NEW). Важная деталь: такой параметр обязателен. Не прислали — Spring ответит400и до тела метода дело не дойдёт. Чтобы параметр стал необязательным, задайтеdefaultValue(как уlimitвыше) или напишитеrequired = false— тогда вместо значения придётnull.@RequestBody— тело запроса. Spring сам прочитает JSON и соберёт из него объект.
Здесь легко перепутать одно с другим: @PathVariable достаёт кусок из самого пути, а @RequestParam — то, что идёт после ?.
Ту же механику видно и без Spring: путь отрезается от строки запроса, по нему ищется обработчик, значения достаются из пути и параметров.
живой пример
import java.util.HashMap;
import java.util.Map;
public class MiniDispatcher {
static String dispatch(String method, String uri) {
String path = uri.split("\\?", 2)[0];
Map<String, String> params = new HashMap<>();
if (uri.contains("?")) {
for (String pair : uri.split("\\?", 2)[1].split("&")) {
String[] kv = pair.split("=", 2);
params.put(kv[0], kv[1]);
}
}
String[] parts = path.split("/");
if (method.equals("GET") && parts.length == 3 && parts[1].equals("orders")) {
return "get(id=" + parts[2] + ")";
}
if (method.equals("GET") && path.equals("/orders")) {
return "list(status=" + params.getOrDefault("status", "ANY")
+ ", limit=" + params.getOrDefault("limit", "10") + ")";
}
if (parts.length == 3 && parts[1].equals("orders")) {
return "405 Method Not Allowed";
}
return "404 Not Found";
}
public static void main(String[] args) {
System.out.println(dispatch("GET", "/orders/5"));
System.out.println(dispatch("GET", "/orders?status=NEW&limit=20"));
System.out.println(dispatch("GET", "/orders"));
System.out.println(dispatch("DELETE", "/orders/5"));
System.out.println(dispatch("GET", "/invoices/5"));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
get(id=5)
list(status=NEW, limit=20)
list(status=ANY, limit=10)
405 Method Not Allowed
404 Not Found
Третья строка — оба значения по умолчанию из @RequestParam(defaultValue = ...): в запросе не было ни status, ни limit. Четвёртая — тот же путь другим методом: Spring отвечает 405, а не 404. Пятая — адреса нет вовсе, вот там 404.
Что вернуть из метода
Клиент создал заказ и получил 200 OK с телом, как при обычном чтении: по ответу не понять, что что-то создано, а у клиента, который ждёт 201 и заголовок Location, интеграция ломается. Чаще всего достаточно вернуть обычный объект: Spring превратит его в JSON и отдаст со статусом 200 OK. Но когда нужно управлять статусом ответа или заголовками, как после создания записи, где принято возвращать 201 Created, есть ResponseEntity.
@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) {
OrderResponse created = orderService.create(request);
return ResponseEntity
.status(HttpStatus.CREATED) // 201 вместо 200
.body(created);
}
Правило простое: если хватает статуса 200 — возвращайте объект напрямую, так короче. Нужен другой статус или заголовки — оборачивайте в ResponseEntity.
Есть и третий способ задать статус, самый дешёвый: повесить @ResponseStatus на своё исключение. Тогда ни ResponseEntity, ни обработчика не нужно — Spring сам превратит исключение в ответ с этим кодом.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) { super("Заказ " + id + " не найден"); }
}
Для простого «не нашли» этого хватает. Ограничение в том, что тело ответа вы так не контролируете, поэтому единый формат ошибки всё равно собирают в одном месте, о чём следующий раздел.
Что делает Jackson
«Spring сам превратит объект в JSON» — это работа библиотеки Jackson, и стоит знать, по каким правилам. Имена полей JSON берутся из имён компонентов record или геттеров класса, поэтому record OrderResponse(UUID id, BigDecimal total) даёт {"id": ..., "total": ...}; record Jackson поддерживает с версии 2.12 без всяких настроек. Обратное преобразование для record идёт через его единственный конструктор, а для обычного класса требует конструктора без аргументов или аннотаций.
Три умолчания, которые меняют в первую же неделю.
Дата без настройки превращается в число. LocalDateTime из java.time Jackson по умолчанию пишет массивом чисел, а не строкой ISO. Spring Boot это уже чинит: стартер подключает модуль jackson-datatype-jsr310 и выставляет spring.jackson.serialization.write-dates-as-timestamps=false, поэтому в Boot-приложении дата приезжает строкой 2026-03-01T10:15:30. Если Jackson используется вне Boot, модуль подключают руками, иначе клиент получит [2026,3,1,10,15,30].
Неизвестное поле во входящем JSON роняет запрос. По умолчанию Jackson бросает UnrecognizedPropertyException, если клиент прислал поле, которого нет в DTO. Spring Boot и это смягчает, выставляя spring.jackson.deserialization.fail-on-unknown-properties=false: лишние поля просто игнорируются, и клиент может добавлять их, не ломая вас. Обратно строгость включают тем же свойством, когда контракт обязан быть точным.
null попадает в ответ. Поля со значением null сериализуются как "field": null; убрать их из ответа целиком можно @JsonInclude(NON_NULL) на DTO или глобально spring.jackson.default-property-inclusion=non_null.
Проверка входных данных
Клиент может прислать что угодно: пустое имя, отрицательное количество, дату из прошлого. Если не проверять, кривые данные уйдут вглубь приложения и сломают что-нибудь там, где разобраться уже трудно. Проверять лучше сразу на входе.
Для этого есть Bean Validation — стандартный механизм, где правила задаются прямо на полях аннотациями. В Spring Boot он подключается зависимостью spring-boot-starter-validation.
public record CreateOrderRequest(
@NotBlank String customerName, // не пустая строка
@NotEmpty List<OrderLineRequest> lines, // список не пустой
@NotNull @Future LocalDateTime deliveryDate // дата в будущем
) {}
Самые ходовые аннотации проверяют то, что чаще всего приходит пустым, и для строки три из них означают разное: @NotNull пропустит "" и " ", @NotEmpty пропустит " ", и только @NotBlank требует хотя бы один непробельный символ; для коллекции «не пусто» это @NotEmpty. Дальше по частоте: @Size для длины в диапазоне, @Min, @Max и @Positive для чисел, @Email, @Future и @Past для дат.
Чтобы Spring реально применил эти правила, на параметре ставят @Valid:
@PostMapping
public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) { ... }
Без @Valid аннотации на полях ничего не делают — это частая причина «почему валидация не сработала». Если данные не прошли проверку, Spring сам отклонит запрос ещё до входа в тело метода.
Обработка ошибок в одном месте
Что-то всегда идёт не так: заказ не найден, данные не прошли проверку, упала база. Если в каждом методе писать try/catch и руками собирать ответ об ошибке, код раздувается и ошибки выглядят по-разному в разных местах. Лучше собрать всю обработку в одном классе.
Для этого есть @RestControllerAdvice — класс, который ловит исключения из всех контроллеров сразу. Внутри — методы с @ExceptionHandler, по одному на тип ошибки.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<String> handleNotFound(OrderNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleUnknown(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Что-то пошло не так");
}
}
Теперь, где бы в контроллере ни выбросили OrderNotFoundException, клиент получит аккуратный ответ 404. А последний метод — «ловушка на всё остальное»: любая неожиданная ошибка превратится в 500, а не в кашу из стектрейса.
У этой ловушки есть обратная сторона. Обработчик на Exception перехватывает и MethodArgumentNotValidException — ту самую ошибку, которую бросает @Valid. В итоге запрос с пустым именем вернёт 500 вместо 400, и клиент решит, что сломался сервер. Поэтому рядом всегда держат отдельный обработчик на MethodArgumentNotValidException — он забирает ошибки валидации раньше, чем до них доберётся ловушка.
Одного такого обработчика мало. Проверки на теле запроса (@Valid @RequestBody) роняют MethodArgumentNotValidException, а проверки на отдельных параметрах — @Min(1) @PathVariable Long id, @Size(max = 50) @RequestParam String status — устроены иначе: их включает @Validated на самом классе контроллера, и нарушение прилетает как ConstraintViolationException. Это другой тип, и ловушка на Exception съест его так же, как съела бы первый. Пользуетесь проверками на параметрах — держите обработчика два.
Писать эти обработчики с нуля не обязательно: в Spring есть базовый класс ровно под ошибки самого фреймворка. ResponseEntityExceptionHandler уже содержит методы для MethodArgumentNotValidException, HttpMessageNotReadableException (кривой JSON), HttpRequestMethodNotSupportedException (405), MissingServletRequestParameterException и десятка их родственников, и все они отдают ProblemDetail с правильным кодом. Ваш advice наследуют от него, добавляя только свои доменные исключения:
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
После этого ловушка на Exception (если она вообще нужна) уже не съест 400: ошибки фреймворка разобраны более точными методами базового класса, а они выигрывают у обработчика на общий тип.
А если своего формата ошибки не требуется вовсе, достаточно одной строки настроек:
spring.mvc.problemdetails.enabled=true
После неё Spring сам отдаёт все ошибки фреймворка в формате ProblemDetail, без единого обработчика; свой advice остаётся нужен только для доменных исключений.
Единый формат ошибки
Чтобы ошибки от всего сервиса выглядели одинаково, в Spring есть готовый формат ответа — ProblemDetail. Это просто объект с понятными полями, который тоже отдаётся как JSON.
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
Ответ получится таким:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Заказ 5 не найден"
}
Свой формат ошибок изобретать не стоит — когда у всех ответов одинаковая структура, клиентам (веб, мобильное приложение) проще их разбирать.
Документация API через OpenAPI
Когда контроллеров становится много, клиентам нужна понятная справка: какие есть адреса, что они принимают и возвращают. Писать её руками и поддерживать в актуальном виде тяжело — она быстро расходится с кодом.
Поэтому документацию генерируют автоматически. Библиотека springdoc-openapi подключается одной зависимостью и сама собирает описание API по вашим контроллерам.
dependencies {
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.9.1")
}
После этого по адресу /swagger-ui.html открывается страница, где видны все адреса, параметры и тела, собранные из тех же @GetMapping, @RequestParam и @RequestBody, что разобраны выше, и любой из них можно вызвать прямо из браузера. Справка строится по коду и не расходится с ним. Как из неё сделать контракт, а не только страницу, разобрано в статье про OpenAPI.
Где заканчивается контроллер
Соблазн вернуть из контроллера сущность JPA велик: она уже есть, поля совпадают, DTO писать лень. Последствий три, и все всплывают не сразу.
Первое: контракт API начинает меняться вместе со схемой базы. Переименовали колонку, добавили служебное поле, поменяли тип идентификатора — у клиентов сломался разбор ответа, хотя API никто не менял.
Второе: наружу уезжает лишнее. В сущности есть поля, которых клиенту видеть не положено: внутренний комментарий, стоимость закупки, хеш пароля у пользователя. Аннотацией @JsonIgnore это затыкают по одному полю, и однажды кто-то добавит новое поле и забудет.
Третье, самое неприятное: сериализация ходит в базу. Jackson обходит все геттеры, включая ленивые связи, и если сессия ещё открыта (см. Open Session In View в статье про Spring Data JPA), каждый такой геттер тихо делает запрос; если уже закрыта — приходит LazyInitializationException прямо во время формирования ответа, когда статус 200 клиенту уже ушёл.
Поэтому контроллер принимает и отдаёт свои типы: CreateOrderRequest на вход, OrderResponse на выход, оба обычно record. Преобразование делают в сервисе или отдельным маппером; для проекций из базы удобно собирать нужный record прямо запросом, минуя сущность. Правило: граница HTTP это отдельный контракт, и он живёт дольше, чем схема таблицы.
Глубже: вызвать другой сервис: RestClient и таймаутырасширенное
Всё выше про то, как принять запрос. Но сервис редко живёт один: заказ идёт в каталог за ценой, в платёжный сервис за подтверждением. Для исходящих вызовов в Spring 6.1 и Boot 3.2 появился RestClient, синхронный клиент с текучим API, который заменил старый RestTemplate.
@Bean
RestClient catalogClient(RestClient.Builder builder) {
return builder
.baseUrl("http://catalog:8080")
.requestFactory(ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(2))
.withReadTimeout(Duration.ofSeconds(5))))
.build();
}
ProductDto product(UUID id) {
return catalogClient.get()
.uri("/api/v1/products/{id}", id)
.retrieve()
.body(ProductDto.class);
}
Две строки с таймаутами не украшение. Без таймаута соединения поток, который зовёт лежащий сервис, ждёт ответа столько, сколько позволит операционная система, минуты; без таймаута чтения ждёт вечно, если сосед принял соединение и завис. Пул потоков Tomcat в двести штук исчерпывается за секунды, и падает уже ваш сервис, хотя лёг чужой. Поэтому таймауты ставят всегда и короче, чем кажется разумным: соединение секунды две, чтение столько, сколько сосед обещает в своём SLA.
Тот же клиент можно не писать руками. Декларативный HTTP-интерфейс описывает вызовы аннотациями @HttpExchange и @GetExchange, а реализацию генерирует HttpServiceProxyFactory поверх RestClient:
interface CatalogApi {
@GetExchange("/api/v1/products/{id}")
ProductDto product(@PathVariable UUID id);
}
Что не меняется ни в одном варианте: у исходящего вызова есть таймауты, ошибка соседа превращается в свою ошибку с понятным кодом (503, а не 500 со стеком), а повторы делают только для идемпотентных запросов. Как защищать вызовы дальше, разобрано в статьях про сеть и устойчивость.
Коротко
DispatcherServlet— единый вход в приложение: все запросы идут через него, а он находит метод по пути и HTTP-методу. Регистрировать его руками не нужно.@RestController+@GetMapping/@PostMapping/... делают метод обработчиком конкретного адреса; возвращённый объект уходит как JSON, а нужен другой статус или заголовки —ResponseEntity.- Данные из запроса достают тремя аннотациями:
@PathVariable(часть пути),@RequestParam(после?),@RequestBody(тело запроса). - Аннотации Bean Validation на полях срабатывают только вместе с
@Validна параметре — без него правила молчат. - Обработку ошибок собирают в одном
@RestControllerAdvice, единый формат ответа —ProblemDetail; ловушке наExceptionнельзя давать съесть400от валидации. - Справку по API не пишут руками — её генерирует springdoc-openapi, страница
/swagger-ui.html. - Исходящие вызовы делает
RestClient(или интерфейс с@HttpExchange); таймауты соединения и чтения обязательны, иначе чужая авария исчерпает ваш пул потоков. - Перед диспетчером стоят фильтры (
OncePerRequestFilter: Security,traceId, логирование; их ошибки до advice не доходят), внутри негоHandlerInterceptor, который знает выбранный метод контроллера. @ResponseStatusна своём исключении это самый дешёвый способ вернуть 404; базовый классResponseEntityExceptionHandlerуже разбирает ошибки фреймворка, аspring.mvc.problemdetails.enabled=trueотдаёт их какProblemDetailбез единого обработчика.- JSON делает Jackson:
recordиз коробки, дата строкой благодаря модулюjsr310, неизвестные поля игнорируются настройкой Boot; контроллер принимает и отдаёт свои DTO, а не сущности базы.
Что почитать дальше
- Spring WebFlux — реактивная альтернатива и когда она действительно нужна.
@Transactionalглубоко — как сделать операции в контроллере транзакционными правильно.- Spring Security — аутентификация и авторизация внутри MVC.
- Spring Testing —
@WebMvcTest,MockMvcи проверка контроллеров.