← назад к разделу

Когда браузер или другой сервис обращается к вашему приложению по 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:

POST /orders · customerName = "Анна" POST /orders · customerName = "" DispatcherServlet единый вход: ищет метод по пути и HTTP-методу нашёл: POST /orders → create(...) @Valid проверяет поля до входа в метод поля в порядке customerName пустой create(...) выполнился 201 Created · тело ответа — JSON @RestControllerAdvice 400 · ProblemDetail, метод не вызван

Через 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 не дойдёт — тот живёт внутри диспетчера, и ошибку фильтра увидит только контейнер.

Tomcat принял соединение фильтры Security, traceId DispatcherServlet ищет обработчик контроллер ваш метод

Запрос идёт слева направо: фильтры видят его раньше диспетчера, поэтому их ошибка до @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 и проверка контроллеров.