Spring MVC Patterns — Servlet Stack
Stack constraint
MVC is blocking, 1 thread per request. NEVER mix with WebFlux. Verify:
spring-boot-starter-webon classpath (nospring-boot-starter-webflux)- All I/O OK to block: JDBC/JPA, Jedis, spring-kafka (blocking consumer fine)
- Throughput: moderate (<1k concurrent) — saturates at thread pool
Controller pattern
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
@Validated
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return createOrderUseCase.execute(request);
}
@GetMapping
public Page<OrderDto> list(@PageableDefault(size = 20) Pageable pageable) {
return orderQueryService.findAll(pageable);
}
}
Rules: @RequiredArgsConstructor, @Valid on request bodies, return DTOs (never entities), Pageable with @Max(100) size cap.
Filter — OncePerRequestFilter
@Component
public class TracingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain)
throws ServletException, IOException {
String requestId = Optional.ofNullable(req.getHeader("X-Request-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("requestId", requestId);
try {
chain.doFilter(req, resp);
} finally {
MDC.clear();
}
}
}
MDC works in MVC (ThreadLocal) — different from WebFlux Reactor Context.
HandlerInterceptor (rate limiting, auth checks)
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final RateLimiter rateLimiter;
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
if (!rateLimiter.tryAcquire(req.getRemoteAddr())) {
resp.setStatus(429);
return false;
}
return true;
}
}
Register via WebMvcConfigurer.addInterceptors().
JPA transactions
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repo;
@Transactional
public Order placeOrder(Order order) {
Order saved = repo.save(order);
eventPublisher.publish(saved);
return saved;
}
@Transactional(readOnly = true)
public Page<Order> findAll(Pageable pageable) {
return repo.findAll(pageable);
}
}
@Transactional(readOnly = true) on query methods — JPA optimization. Always set spring.jpa.open-in-view: false.
Security filter
Servlet security via SecurityFilterChain (NOT SecurityWebFilterChain):
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
Method security: @EnableMethodSecurity (not @EnableReactiveMethodSecurity).
HTTP client — RestClient (Spring 6.1+) or RestTemplate
@Configuration
public class HttpClientConfig {
@Bean
public RestClient userServiceClient(@Value("${user-service.url}") String baseUrl) {
return RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(new SimpleClientHttpRequestFactory() {{
setConnectTimeout(2000);
setReadTimeout(5000);
}})
.build();
}
}
Prefer RestClient over RestTemplate in Spring 6.1+. ALWAYS set timeouts.
Testing — MockMvc
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean CreateOrderUseCase createOrderUseCase;
@Test
void shouldCreateOrderWhenValidRequest() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":\"p1\",\"quantity\":2}"))
.andExpect(status().isCreated());
}
}
@WebMvcTest slices context to controller layer. Mock use case.
Anti-patterns
| Anti-pattern | Fix |
|---|---|
@Autowired field injection |
@RequiredArgsConstructor |
| Entities in API responses | Map to DTOs |
spring.jpa.open-in-view: true |
Set to false |
RestTemplate without timeouts |
Always set connect + read timeouts |
Missing @Transactional(readOnly=true) on queries |
Add it — JPA performance |
Returning Page<Entity> |
Map to Page<EntityDto> |
Unbounded Pageable size |
@Max(100) on size parameter |
RestTemplate in Spring 6.1+ |
Prefer RestClient |
Verification checklist
-
@RequiredArgsConstructor(no@Autowired) -
@Validon request bodies - DTOs returned (never entities)
-
@Transactional(readOnly = true)on queries -
spring.jpa.open-in-view: false - Pagination capped (
@Max(100)) - HTTP client timeouts configured
- MockMvc tests cover controllers
- Graceful shutdown + actuator configured
References
- references/spring-mvc.md — Full controller patterns, MockMvc, filters, interceptors, pagination
- references/springboot-production.md — Caching, async, rate limiting, Jackson, HikariCP, graceful shutdown
- references/springboot-3x-features.md — Virtual threads (3.2+),
@HttpExchange, Observation API
Related
skills/spring-security—SecurityFilterChainskills/database-patterns— JPA repository patternsskills/api-design— REST conventions, RFC 7807skills/testing-workflow— MockMvc, @WebMvcTest