Skip to content
Backend Development

Spring Boot Request Lifecycle Explained: From HTTP to Database

Follow a single HTTP request through filters, the DispatcherServlet, your controller, the service layer and the database — and learn where each concern belongs.

Aditya Sharma4 min read
Diagram of a request flowing from client through filter chain, DispatcherServlet, controller and service to the database
On this page

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:

  1. Asks its HandlerMappings which controller method matches the URL and HTTP method.
  2. Runs any HandlerInterceptors registered for that path (preHandle).
  3. Uses a HandlerAdapter to invoke the method, resolving arguments such as @PathVariable, @RequestParam and @RequestBody.
  4. Converts the return value to a response through HttpMessageConverters (usually Jackson for JSON).
  5. 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 slug and status needs 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?

ConcernLayer
Authentication, request IDs, CORSFilters
Per-route pre/post logic (e.g. auditing)Interceptors
Input mapping and validationController
Business rules, transactionsService
Queries and persistenceRepository
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?

Written by

Aditya Sharma

Backend developer · Java & APIs

Enjoys taking apart backend frameworks to see how requests really flow, and writing down what he finds. (Demo author profile.)

Comments

Join the discussion

Your email address will not be published. Be kind and stay on topic.