We're dissecting how Spring MVC manages every HTTP request through a single, central class: DispatcherServlet. Spring MVC is a web framework built around the Front Controller pattern, and this servlet is its traffic cop for logging, routing, error handling, uploads, async, and view rendering. Yet it does all of this without leaking into your controllers or domain code. I'm Mahmoud Zalt, an AI solutions architect, and we'll use DispatcherServlet as a concrete template for designing a powerful front controller that orchestrates everything but owns no business logic.
A Single Entry Point That Only Orchestrates
Before touching specific methods, we need a clear model of what this servlet is doing — and what it refuses to do. That model is the core lesson you can reuse in any framework.
DispatcherServlet is a central router that never contains domain logic; it only coordinates other components that do the real work.
The servlet is a textbook Front Controller: a single entry point for HTTP requests that delegates to handlers. In Spring MVC, that delegation looks like:
- Finding a handler through
HandlerMappingimplementations. - Invoking the handler via a matching
HandlerAdapter. - Letting
HandlerExceptionResolverinstances turn exceptions into error responses. - Resolving and rendering views via
ViewResolverandView.
This servlet combines Strategy, Chain of Responsibility, Interceptor, and Template Method patterns to stay central but decoupled. It owns the sequence of steps, not the behavior of each step.
spring-framework/
spring-webmvc/
src/main/java/
org/springframework/web/servlet/
FrameworkServlet.java
DispatcherServlet.java <-- front controller
HandlerMapping.java
HandlerAdapter.java
HandlerExceptionResolver.java
ViewResolver.java
View.java
...
Client -> ServletContainer -> DispatcherServlet.doService()
|
v
DispatcherServlet.doDispatch()
|
+-------------------------+---------------------------+
v v v
HandlerMappings[] HandlerAdapters[] HandlerExceptionResolvers[]
| | |
v v v
Handler ModelAndView ModelAndView (error)
|
v
ViewResolvers[] -> View -> HTTP Response
DispatcherServlet as a routing hub: one entry, many strategies.Inside the Dispatch Pipeline
With the role clear, we can walk the lifecycle and see how the servlet stays an orchestrator instead of a god object. The structure is the real design value.
The servlet breaks a complex flow into phases, each replaceable via interfaces, while keeping a single, predictable pipeline.
1. Service entry: prepare, don’t decide
Every mapped request first hits doService. This method prepares the environment, then delegates the actual work to doDispatch:
- Logs the request with safe defaults (parameters and headers masked unless detailed logging is explicitly enabled).
- Snapshots request attributes for
includedispatches and restores them later. - Attaches framework attributes such as
WebApplicationContext,LocaleResolver, and flash maps. - Optionally parses and caches
RequestPathwhen path-pattern mappings are enabled.
No controllers, no views, no domain decisions — only setup and delegation.
2. Dispatch core: handler, adapter, view
The heart of the servlet is doDispatch. It is long, but conceptually simple once you see the phases:
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
boolean multipartRequestParsed = false;
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
try {
ModelAndView mv = null;
Exception dispatchException = null;
try {
processedRequest = checkMultipart(request);
multipartRequestParsed = (processedRequest != request);
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
applyDefaultViewName(processedRequest, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
}
catch (Exception ex) {
dispatchException = ex;
}
catch (Throwable err) {
dispatchException = new ServletException("Handler dispatch failed: " + err, err);
}
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}
catch (Exception ex) {
triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
}
catch (Throwable err) {
triggerAfterCompletion(processedRequest, response, mappedHandler,
new ServletException("Handler processing failed: " + err, err));
}
finally {
if (asyncManager.isConcurrentHandlingStarted()) {
if (mappedHandler != null) {
mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
}
asyncManager.setMultipartRequestParsed(multipartRequestParsed);
}
else {
if (multipartRequestParsed || asyncManager.isMultipartRequestParsed()) {
cleanupMultipart(processedRequest);
}
}
}
}
This boils down to a few reusable ideas:
- Request adaptation:
checkMultipartwraps the request inMultipartHttpServletRequestwhen needed, keeping upload concerns out of controllers. - Routing:
getHandlerwalks a list ofHandlerMappinginstances until one returns aHandlerExecutionChainfor the request. - Cross-cutting concerns: Interceptors inside
HandlerExecutionChaingetpreHandleandpostHandlehooks for logging, metrics, auth, and similar concerns. - Invocation: A
HandlerAdapterchooses how to invoke the handler (classicController, annotated method, etc.) and returns aModelAndView. - Async and cleanup: If async processing starts, normal rendering stops early, and
afterCompletionorapplyAfterConcurrentHandlingStartedare still guaranteed, plus multipart cleanup now or later.
3. Strategy initialization: plug-and-play by default
All those collaborators are wired once when the application context is ready. initStrategies shows how to bootstrap a flexible pipeline with a tiny template:
protected void initStrategies(ApplicationContext context) {
initMultipartResolver(context);
initLocaleResolver(context);
initHandlerMappings(context);
initHandlerAdapters(context);
initHandlerExceptionResolvers(context);
initRequestToViewNameTranslator(context);
initViewResolvers(context);
initFlashMapManager(context);
}
Each init* method follows the same pattern:
- Discover beans for a given interface (for example, all
HandlerMappinginstances), or a single named bean, depending on flags likedetectAllHandlerMappings. - Fall back to defaults from
DispatcherServlet.propertiesviagetDefaultStrategieswhen none are defined. - Sort lists using
AnnotationAwareOrderComparatorso ordering annotations or interfaces control precedence.
This makes the servlet generic and stable: it only knows about interfaces and default strategies. Applications can customize almost any stage just by defining new beans, without subclassing or forking DispatcherServlet.
Centralized, Composable Error Handling
Once the happy path is clear, the next question is how the servlet handles failures without turning into a tangle of try/catch blocks or half-written responses.
The servlet centralizes error handling while keeping the mapping from exceptions to responses completely pluggable.
From exception to error view
processDispatchResult is the bridge between normal handler output and error handling. It looks at the current ModelAndView plus any exception and decides what to render:
ModelAndViewDefiningExceptioncarries its ownModelAndViewthat can be used directly.- For other exceptions,
processHandlerExceptionis called to consultHandlerExceptionResolverstrategies. - Once an error view is rendered, error attributes are cleaned up to avoid leaking into subsequent includes or forwards.
The core error pipeline is in processHandlerException:
HandlerExceptionResolver (GitHub)protected @Nullable ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
@Nullable Object handler, Exception ex) throws Exception {
request.removeAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
try {
response.setHeader(HttpHeaders.CONTENT_TYPE, null);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, null);
response.resetBuffer();
}
catch (IllegalStateException illegalStateException) {
// response already committed
}
ModelAndView exMv = null;
if (this.handlerExceptionResolvers != null) {
for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
exMv = resolver.resolveException(request, response, handler, ex);
if (exMv != null) {
break;
}
}
}
if (exMv != null) {
if (exMv.isEmpty()) {
request.setAttribute(EXCEPTION_ATTRIBUTE, ex);
return null;
}
if (!exMv.hasView()) {
String defaultViewName = getDefaultViewName(request);
if (defaultViewName != null) {
exMv.setViewName(defaultViewName);
}
}
WebUtils.exposeErrorRequestAttributes(request, ex, getServletName());
return exMv;
}
throw ex;
}
Key practices worth copying:
- Response hygiene: Content headers are cleared and the buffer is reset where possible, so error views don’t append onto partial normal responses.
- Chain of Responsibility: Multiple
HandlerExceptionResolverinstances can each decide whether they handle an exception. The first non-nullModelAndViewwins. - Explicit semantics: An empty
ModelAndViewmeans “no view, but exception exposed as a request attribute”, which is useful for resolvers that only adjust status codes.
Multipart and async: tricky flows, clear hooks
Multipart uploads and async requests tend to cause subtle bugs. DispatcherServlet isolates both through clear contracts:
- Multipart:
checkMultipartresolves the multipart request once, detects previousMultipartExceptionviahasMultipartException, and lets error dispatch flows keep using the original request when resolution fails. - Async:
WebAsyncManager.isConcurrentHandlingStarted()short-circuits normal rendering when async begins. Interceptors getapplyAfterConcurrentHandlingStarted, and multipart state is recorded viasetMultipartRequestParsedfor later cleanup.
The important part is the contract, not the branching: regardless of success, exception, or async handoff, interceptors see a completion callback and resources such as multipart uploads are cleaned up now or safely deferred.
Overhead, Scale, and Observability
So far we focused on structure. To use this pattern in real systems, we also need to understand its cost and how to watch it in production.
The servlet keeps per-request overhead predictable and mostly linear in the number of strategies, while exposing the right hooks for monitoring.
Algorithmic cost: linear in strategies
Per request, the servlet’s own work is linear in the number of configured strategies:
MHandlerMappinginstances for handler lookup.AHandlerAdapterinstances for adapter selection.RHandlerExceptionResolverinstances on error paths.VViewResolverinstances for view resolution.
In most applications these numbers are small (often just a handful each), so dispatch overhead is dominated by controller and view work. Strategy lists are initialized once on startup, sorted, then treated as immutable, which keeps concurrent reads cheap.
| Component | Per-request complexity | Who does the heavy work? |
|---|---|---|
| Handler resolution | O(M) | Each HandlerMapping implementation |
| Adapter selection | O(A) | Simple supports() checks |
| View resolution | O(V) | ViewResolver plus template engine |
| Error resolution | O(R) | HandlerExceptionResolver logic |
Hot paths and logging risks
The hot methods are exactly the ones you’d expect: doService, doDispatch, getHandler, getHandlerAdapter, and view resolution. Within these, the main latency risk is unnecessary work, especially in logging.
The request logging logic is defensive by default:
- Masks parameters and headers unless
isEnableLoggingRequestDetails()is explicitly enabled. - Avoids parsing request bodies purely for logging.
- Builds detailed header strings only at trace-level logging.
But if you enable detailed logging in production, building large parameter and header strings for every request can add CPU and allocation overhead, increasing GC pressure and tail latency. The design supports detailed logging; operations must decide when they can afford it.
Metrics that make the front controller observable
A front controller is a natural choke point for instrumentation. The servlet lends itself to a small set of high-signal metrics:
dispatcher.requests.total– total requests through the servlet.dispatcher.requests.duration– latency histogram or percentiles at the front-controller layer.dispatcher.exceptions.total– handled and unhandled exceptions, ideally by type.dispatcher.no_handler_found.total– cases where no handler was found (404-like conditions).dispatcher.multipart.active_uploads– concurrent multipart uploads.dispatcher.async.requests.in_flight– async requests currently in progress.
With these, plus focused logs and traces, your front controller stops being a black box and becomes an observable layer you can reason about under load.
What to Steal for Your Own Systems
We’ve seen how DispatcherServlet coordinates routing, uploads, async, error handling, and view resolution without ever knowing your domain. That’s the real design win.
The core lesson: concentrate control in a front controller, but push behavior out to strategies and handlers so the center stays small, stable, and reusable.
1. Keep the front controller orchestral, not musical
Your front controller should:
- Own the lifetime of a request: logging, context setup, routing, error translation, and cleanup.
- Delegate all business decisions to handlers, interceptors, or domain services.
- Expose clear extension points (interfaces, hooks) for application-specific behavior.
If you see domain rules creeping into the central router, extract them into handlers or middleware layers.
2. Model flows as ordered strategy chains
The way Spring models handler mappings, adapters, views, and exception resolvers is a reusable blueprint:
- Define an interface per stage in the pipeline.
- Initialize and sort strategy lists once; then treat them as read-only.
- Walk each list linearly until one claims responsibility for the current request or exception.
This gives you the extension benefits of Chain of Responsibility without losing the clarity of a single pipeline.
3. Make failure flows first-class
The servlet’s error path is as deliberate as its happy path:
- Exceptions funnel through a single method that manages response state.
- Error handling strategies are pluggable and ordered.
- Interceptors always get an
afterCompletioncallback, even when things go wrong or go async.
In your own systems, invest in a central, composable error pipeline instead of scattered try/catch blocks around the codebase.
4. Balance observability with cost
The servlet is designed to be observable without being noisy:
- Logging defaults to conservative, with opt-in detailed modes.
- Metrics focus on a small set of counters and timers that reflect the health of the whole pipeline.
- Async and multipart branches have explicit hooks and flags.
When you design a front controller, make it easy to answer “what is it doing?” and “how healthy is it?” without turning every request into a profiling session.
Spring’s DispatcherServlet has routed HTTP requests for years across diverse applications, and its design still holds up: one powerful front controller that mostly stays out of your way. If you build gateways, API routers, or any request dispatcher, this playbook is worth copying — centralize the flow, keep the core ignorant of your domain, and move nearly everything else into strategies you can swap, extend, and observe.



