Spring MVC — синхронный веб-стек поверх Servlet API. С Spring Boot 3 на нём строятся почти все типичные backend-сервисы: REST API, internal HTTP-эндпоинты, веб-админки. Альтернатива — WebFlux — нужна реже, чем многие думают.
DispatcherServlet и поток запроса
HTTP request
↓
[Servlet Container: Tomcat]
↓
[Filter Chain] ← OncePerRequestFilter, security filters
↓
[DispatcherServlet]
↓
[HandlerMapping] ← найти контроллер по URL
↓
[HandlerInterceptor.preHandle] ← AOP-подобная точка до контроллера
↓
[HandlerAdapter → @RequestMapping] ← вызов метода контроллера
↓
[HandlerInterceptor.postHandle]
↓
[ViewResolver или MessageConverter] ← сериализация ответа
↓
HTTP response
Знать этот поток нужно, потому что 80% «магических» проблем («почему фильтр не вызвался», «почему @RestControllerAdvice не поймал исключение», «почему content-type не такой как ожидался») разрешаются через понимание точки в цепочке.
REST-контроллер
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final CreateOrderUseCase createOrder;
private final FindOrderUseCase findOrder;
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
OrderId id = createOrder.handle(request.toCommand());
return ResponseEntity
.created(URI.create("/api/v1/orders/" + id.value()))
.body(new OrderResponse(id.value()));
}
@GetMapping("/{id}")
public OrderResponse get(@PathVariable UUID id) {
return findOrder.handle(new OrderId(id))
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
@RestController=@Controller+@ResponseBody(методы возвращают тело ответа, не view).@RequestMappingна классе — общий префикс.@PostMapping/@GetMapping— shortcut для@RequestMapping(method = POST).ResponseEntityвозвращаем, когда нужно явно контролировать статус и заголовки. Иначе — обычный объект (status 200).
Bean Validation
Валидация входящих DTO — через Jakarta Bean Validation (бывший JSR-380). В Spring Boot подключается автоматически через spring-boot-starter-validation.
public record CreateOrderRequest(
@NotBlank @Size(max = 200) String customerName,
@NotEmpty @Size(min = 1, max = 50) List<@Valid OrderLineRequest> lines,
@NotNull @Future LocalDateTime deliveryDate
) {}
public record OrderLineRequest(
@NotBlank String sku,
@Positive int quantity,
@NotNull @PositiveOrZero BigDecimal price
) {}
Активация — через @Valid на параметре:
@PostMapping
public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) { ... }
@Valid — каскадно валидирует вложенные объекты (массивы List<@Valid OrderLineRequest> тоже).
Альтернатива — @Validated на классе контроллера + @Min/@Max на параметрах метода:
@RestController
@Validated
public class ProductController {
@GetMapping("/products")
public List<Product> list(
@RequestParam @Min(1) @Max(100) int limit,
@RequestParam @PositiveOrZero int offset) { ... }
}
@Validated валидирует на уровне метода (через AOP), @Valid — на уровне объекта.
Единая обработка ошибок
@RestControllerAdvice — @RestController для исключений. Один класс на сервис, в нём — все @ExceptionHandler.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
var problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation failed");
problem.setDetail("Request body did not pass validation");
problem.setProperty("violations", ex.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
.toList());
return problem;
}
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnknown(Exception ex) {
log.error("Unhandled exception", ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"Internal server error");
}
}
ProblemDetail (RFC 7807)
С Spring 6/Boot 3 стандарт для ошибок — ProblemDetail (RFC 7807). Формат ответа фиксирован:
{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"detail": "Request body did not pass validation",
"instance": "/api/v1/orders",
"violations": [
{"field": "customerName", "message": "must not be blank"}
]
}
Не переизобретайте свой формат ошибок — Problem Details работает с UI/мобильными клиентами/API gateway единообразно.
HandlerInterceptor vs OncePerRequestFilter
Оба перехватывают запросы. Выбор не очевиден.
| OncePerRequestFilter | HandlerInterceptor | |
|---|---|---|
| Когда срабатывает | До DispatcherServlet | После DispatcherServlet, до контроллера |
| Доступ к Handler (Controller method) | Нет | Да |
| Зависимости от Spring MVC | Нет (servlet-уровень) | Да (mvc-уровень) |
| Типичное применение | Security, MDC, audit logging, CORS | Per-method авторизация, response shaping |
Правило: если фильтру не нужно знать про конкретный контроллер — OncePerRequestFilter. Иначе — HandlerInterceptor.
@Component
public class CorrelationIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws IOException, ServletException {
String correlationId = Optional.ofNullable(req.getHeader("X-Correlation-ID"))
.orElseGet(() -> UUID.randomUUID().toString());
MDC.put("correlationId", correlationId);
try {
res.setHeader("X-Correlation-ID", correlationId);
chain.doFilter(req, res);
} finally {
MDC.remove("correlationId");
}
}
}
Content negotiation
Spring сам выбирает MessageConverter под Accept-заголовок клиента. Стандарт — JSON через Jackson. Подключение XML или YAML — добавлением соответствующих converters в classpath или в WebMvcConfigurer.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.defaultContentType(MediaType.APPLICATION_JSON)
.ignoreAcceptHeader(false)
.favorParameter(true)
.parameterName("format")
.mediaType("json", MediaType.APPLICATION_JSON)
.mediaType("xml", MediaType.APPLICATION_XML);
}
}
?format=xml → XML. Без параметра / Accept: application/json → JSON.
OpenAPI
Документация — через springdoc-openapi. Подключение одной зависимостью:
dependencies {
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.x.x")
}
OpenAPI-спецификация автоматически генерируется по @RestController + @Schema-аннотациям на DTO. UI доступен по /swagger-ui.html.
Что почитать дальше
- Spring WebFlux — reactive-альтернатива и когда она нужна.
@Transactionalглубоко — как пометить операции в контроллере как транзакционные правильно.- Spring Security — authentication/authorization внутри MVC.
- Spring Testing —
@WebMvcTest,MockMvc, тестирование контроллеров.