When a Spring Boot endpoint is slow, returns the wrong status code or leaks a transaction, the fix is usually easier once you know where in the request lifecycle the problem lives. This article follows one request — GET /api/articles/java-21 — from the network to the database and back, stopping at each layer to explain what it does and what belongs there.
The examples assume Spring Boot 3 with Spring MVC (the servlet stack) and Spring Data JPA.
1. The embedded server accepts the connection
Spring Boot starts an embedded servlet container, Tomcat by default. Tomcat accepts the TCP connection, parses the HTTP request and assigns it to a worker thread (or a virtual thread, if you enabled them). At this point Spring has not been involved yet.
Two settings matter here in production: the maximum number of worker threads and connection timeouts. They are set with server.tomcat.* properties and determine how many requests can be processed concurrently.
2. The filter chain
Before a request reaches Spring MVC, it passes through a chain of servlet filters. Spring Security is implemented as filters, and so are CORS handling, request logging and many observability tools.
@Component
public class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("requestId", requestId);
response.setHeader("X-Request-Id", requestId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("requestId");
}
}
}
Belongs here: cross-cutting concerns that apply to raw HTTP — authentication, request IDs, compression, rate limiting. Does not belong here: business logic.
3. The DispatcherServlet
The DispatcherServlet is the front controller of Spring MVC. For every request it:
- Asks its
HandlerMappings which controller method matches the URL and HTTP method. - Runs any
HandlerInterceptors registered for that path (preHandle). - Uses a
HandlerAdapterto invoke the method, resolving arguments such as@PathVariable,@RequestParamand@RequestBody. - Converts the return value to a response through
HttpMessageConverters (usually Jackson for JSON). - Delegates exceptions to
HandlerExceptionResolvers, including your@ControllerAdvice.
Understanding this sequence explains many “mysteries”. For example, a 400 Bad Request with no log from your controller usually means argument resolution failed — the body could not be parsed or validation failed — before your method ever ran.
4. The controller
Controllers translate HTTP into method calls and method results back into HTTP. Keep them thin.
@RestController
@RequestMapping("/api/articles")
class ArticleController {
private final ArticleService service;
ArticleController(ArticleService service) {
this.service = service;
}
@GetMapping("/{slug}")
ArticleResponse read(@PathVariable String slug) {
return service.findPublished(slug);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
ArticleResponse create(@Valid @RequestBody CreateArticleRequest request) {
return service.create(request);
}
}
Belongs here: mapping, validation annotations, choosing status codes. Does not belong here: transactions, repository calls, complex branching.
5. The service layer and transactions
The service holds business rules and defines transaction boundaries. @Transactional works through a proxy: Spring wraps your bean, opens a transaction before the method and commits or rolls back after it.
@Service
class ArticleService {
private final ArticleRepository repository;
ArticleService(ArticleRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public ArticleResponse findPublished(String slug) {
return repository.findBySlugAndStatus(slug, Status.PUBLISHED)
.map(ArticleResponse::from)
.orElseThrow(() -> new ArticleNotFoundException(slug));
}
}
A classic trap: calling a @Transactional method from another method in the same class bypasses the proxy, so no transaction is started. If a transaction mysteriously does not apply, check for self-invocation first.
Also remember that a runtime exception triggers rollback by default, while checked exceptions do not unless you configure rollbackFor.
6. The repository and the database
Spring Data JPA generates the repository implementation. When findBySlugAndStatus runs, Hibernate builds SQL, borrows a connection from the pool (HikariCP by default), executes the query and maps rows to entities.
Performance problems often hide here:
- N+1 queries: loading a list of articles and then lazily loading each author triggers one extra query per row. Use fetch joins or entity graphs for the cases that need related data.
- Pool exhaustion: long transactions keep connections busy. Keep transactions short and avoid calling slow external APIs inside them.
- Missing indexes: a query on
slugandstatusneeds an index that matches it.
Turn on SQL logging in development (spring.jpa.show-sql=true or a proper logger configuration) and read the queries your code produces.
7. The way back: serialisation and error handling
The returned ArticleResponse is written as JSON by Jackson. If something threw an exception, your @RestControllerAdvice decides the response:
@RestControllerAdvice
class ApiErrors {
@ExceptionHandler(ArticleNotFoundException.class)
ProblemDetail notFound(ArticleNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setDetail("No published article with slug " + ex.slug());
return problem;
}
}
ProblemDetail follows the RFC 9457 format, giving clients a consistent error shape.
Where does each concern belong?
| Concern | Layer |
|---|---|
| Authentication, request IDs, CORS | Filters |
| Per-route pre/post logic (e.g. auditing) | Interceptors |
| Input mapping and validation | Controller |
| Business rules, transactions | Service |
| Queries and persistence | Repository |
| Error-to-HTTP translation | @ControllerAdvice |
Once you can picture this pipeline, debugging becomes a matter of asking one question: which layer saw the request last?
Comments