Skip to main content
المدونة

Zalt Blog

Deep Dives into Code & Architecture

AT SCALE

The Front Controller That Stays Out Of Your Way

By محمود الزلط
Code Cracking
20m read
<

Most front controllers become god objects. “The Front Controller That Stays Out Of Your Way” explores how to keep one central entry point without cluttering your code.

/>
The Front Controller That Stays Out Of Your Way - Featured blog post image

MENTORING

1:1 engineering mentorship.

Architecture, AI systems, career growth. Ongoing or one-off.

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 HandlerMapping implementations.
  • Invoking the handler via a matching HandlerAdapter.
  • Letting HandlerExceptionResolver instances turn exceptions into error responses.
  • Resolving and rendering views via ViewResolver and View.

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 include dispatches and restores them later.
  • Attaches framework attributes such as WebApplicationContext, LocaleResolver, and flash maps.
  • Optionally parses and caches RequestPath when 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:

Core dispatch loop (GitHub)
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:

  1. Request adaptation: checkMultipart wraps the request in MultipartHttpServletRequest when needed, keeping upload concerns out of controllers.
  2. Routing: getHandler walks a list of HandlerMapping instances until one returns a HandlerExecutionChain for the request.
  3. Cross-cutting concerns: Interceptors inside HandlerExecutionChain get preHandle and postHandle hooks for logging, metrics, auth, and similar concerns.
  4. Invocation: A HandlerAdapter chooses how to invoke the handler (classic Controller, annotated method, etc.) and returns a ModelAndView.
  5. Async and cleanup: If async processing starts, normal rendering stops early, and afterCompletion or applyAfterConcurrentHandlingStarted are 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:

Strategy initialization (GitHub)
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 HandlerMapping instances), or a single named bean, depending on flags like detectAllHandlerMappings.
  • Fall back to defaults from DispatcherServlet.properties via getDefaultStrategies when none are defined.
  • Sort lists using AnnotationAwareOrderComparator so 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:

  • ModelAndViewDefiningException carries its own ModelAndView that can be used directly.
  • For other exceptions, processHandlerException is called to consult HandlerExceptionResolver strategies.
  • 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:

Error processing via 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 HandlerExceptionResolver instances can each decide whether they handle an exception. The first non-null ModelAndView wins.
  • Explicit semantics: An empty ModelAndView means “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: checkMultipart resolves the multipart request once, detects previous MultipartException via hasMultipartException, and lets error dispatch flows keep using the original request when resolution fails.
  • Async: WebAsyncManager.isConcurrentHandlingStarted() short-circuits normal rendering when async begins. Interceptors get applyAfterConcurrentHandlingStarted, and multipart state is recorded via setMultipartRequestParsed for 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:

  • M HandlerMapping instances for handler lookup.
  • A HandlerAdapter instances for adapter selection.
  • R HandlerExceptionResolver instances on error paths.
  • V ViewResolver instances 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 afterCompletion callback, 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.

Full Source Code

Here's the full source code of the file that inspired this article.
Read on GitHub

Thanks for reading! I hope this was useful. If you have questions or thoughts, feel free to reach out.

Content Creation Process: This article was generated via a semi-automated workflow using AI tools. I prepared the strategic framework, including specific prompts and data sources. From there, the automation system conducted the research, analysis, and writing. The content passed through automated verification steps before being finalized and published without manual intervention.

Mahmoud Zalt

About the Author

I’m Zalt, a technologist with 16+ years of experience, passionate about designing and building AI systems that move us closer to a world where machines handle everything and humans reclaim wonder.

Let's connect if you're working on interesting AI projects, looking for technical advice or want to discuss anything.

Support this content

Share this article