Spring Framework

Spring MVC: DispatcherServlet, controladores, formularios y vistas

Intermedio7 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Spring MVC es un framework de tipo front controller construido sobre la API Servlet. Esta guía sigue una petición desde el contenedor de servlets a través del handler mapping, la resolución de argumentos, tu controlador, el modelo y el view resolver, y después pasa a la práctica: arranque con configuración Java, WebMvcConfigurer, binding de formularios con Jakarta Bean Validation, redirecciones con flash attributes, interceptores y gestión de excepciones para páginas renderizadas en el servidor.

Lo que aprenderás

  • Describir el ciclo de vida de una petición dentro de DispatcherServlet y nombrar las interfaces de estrategia implicadas
  • Arrancar Spring MVC con un WebApplicationInitializer y configurarlo mediante WebMvcConfigurer
  • Escribir controladores que mapeen peticiones, enlacen y validen datos de formulario y rellenen un modelo
  • Implementar el patrón Post/Redirect/Get con RedirectAttributes y el flash scope
  • Añadir interceptores, el servicio de recursos estáticos y una gestión de excepciones orientada a vistas

Spring MVC es la forma estándar de construir aplicaciones web renderizadas en el servidor sobre la JVM desde mucho antes de Spring Boot, y además es el motor que hay detrás de @RestController. Sigue el patrón front controller: un único servlet recibe todas las peticiones y delega en estrategias intercambiables la decisión de qué handler se ejecuta, cómo se obtienen los argumentos y cómo el resultado se convierte en una respuesta. Conocer esas estrategias hace que el framework deje de ser un conjunto de anotaciones mágicas y pase a ser algo que puedes extender y depurar. Esta guía se centra en Spring Framework 6.x, que requiere la API Jakarta Servlet 6.0 (Tomcat 10.1, Jetty 12 o equivalente) y Java 17 o posterior.

El ciclo de vida de una petición

Toda petición llega a DispatcherServlet, que ejecuta siempre la misma secuencia:

  1. Handler mapping. El servlet pide un handler a cada HandlerMapping. RequestMappingHandlerMapping hace coincidir las anotaciones @RequestMapping por ruta, método, cabeceras, parámetros y tipos de contenido; SimpleUrlHandlerMapping sirve los recursos estáticos. El resultado es un handler más la lista ordenada de HandlerInterceptor que se le aplican.
  2. Interceptores pre-handle. Se ejecuta el preHandle de cada interceptor, que puede detener la petición devolviendo false.
  3. Handler adapter. RequestMappingHandlerAdapter invoca tu método del controlador. Para ello resuelve cada parámetro mediante un HandlerMethodArgumentResolver —variables de ruta, parámetros de la petición, el modelo, objetos de formulario, el principal— y, cuando el método termina, pasa el valor de retorno a un HandlerMethodReturnValueHandler, que decide si es un nombre de vista, un modelo, una ResponseEntity o un cuerpo que hay que serializar.
  4. Los interceptores post-handle se ejecutan con el ModelAndView resultante.
  5. Resolución de la vista. Un ViewResolver convierte el nombre lógico de la vista en una View —una plantilla Thymeleaf, una JSP, un generador de PDF— y la vista renderiza el modelo en la respuesta.
  6. Los interceptores after-completion se ejecutan en un finally, tanto si el renderizado ha ido bien como si se ha lanzado una excepción.
  7. Ante cualquier excepción, se consultan en orden los HandlerExceptionResolver: ExceptionHandlerExceptionResolver localiza los métodos @ExceptionHandler, ResponseStatusExceptionResolver respeta @ResponseStatus y DefaultHandlerExceptionResolver traduce las excepciones de binding y conversión propias de Spring a códigos 4xx.

Otras estrategias completan el cuadro: LocaleResolver para la i18n, MultipartResolver para la subida de ficheros (desde la 6.0 solo queda StandardServletMultipartResolver, basado en el estándar Servlet; el soporte de Commons FileUpload se eliminó) y FlashMapManager para los atributos que sobreviven a una redirección. Cada una tiene una implementación por defecto que se sustituye declarando un bean con el mismo nombre.

Arrancar sin web.xml

Un contenedor Servlet 3.0+ detecta las implementaciones de WebApplicationInitializer en el classpath y las invoca al arrancar. Spring proporciona una clase base abstracta que construye los dos contextos y registra el servlet:

public class ShopWebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {

    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class<?>[] { AppConfig.class, PersistenceConfig.class };   // shared beans
    }

    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class<?>[] { WebConfig.class };                            // web beans
    }

    @Override
    protected String[] getServletMappings() {
        return new String[] { "/" };
    }

    @Override
    protected Filter[] getServletFilters() {
        return new Filter[] { new CharacterEncodingFilter("UTF-8", true) };
    }
}

Esto es lo que el arranque con servidor embebido de Spring Boot hace por ti, aunque de otra manera: Boot crea un único contexto, registra DispatcherServlet como bean y prescinde por completo del inicializador. El viejo web.xml, con un elemento <servlet> y parámetros contextConfigLocation, sigue funcionando y es habitual en aplicaciones desplegadas como WAR en un servidor ya existente.

La propia configuración web activa MVC y lo personaliza mediante WebMvcConfigurer, nunca extendiendo clases internas:

@Configuration
@EnableWebMvc
@ComponentScan("com.example.shop.web")
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
                .addResourceLocations("classpath:/static/")
                .setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic());
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LocaleChangeInterceptor());
        registry.addInterceptor(new RequestTimingInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/about").setViewName("about");   // no controller needed
    }

    @Bean
    public SpringResourceTemplateResolver templateResolver(ApplicationContext ctx) {
        var resolver = new SpringResourceTemplateResolver();
        resolver.setApplicationContext(ctx);
        resolver.setPrefix("classpath:/templates/");
        resolver.setSuffix(".html");
        return resolver;
    }

    @Bean
    public SpringTemplateEngine templateEngine(SpringResourceTemplateResolver resolver) {
        var engine = new SpringTemplateEngine();
        engine.setTemplateResolver(resolver);
        return engine;
    }

    @Bean
    public ThymeleafViewResolver viewResolver(SpringTemplateEngine engine) {
        var resolver = new ThymeleafViewResolver();
        resolver.setTemplateEngine(engine);
        return resolver;
    }
}

@EnableWebMvc importa el RequestMappingHandlerMapping por defecto, el adapter, los exception resolvers y los message converters, y aplica todos los beans WebMvcConfigurer que encuentre. En Spring Boot se omite @EnableWebMvc —añadirla desactiva la autoconfiguración de MVC de Boot— y basta con implementar WebMvcConfigurer.

Spring 6.0 trajo dos cambios en el matching de rutas que pillan por sorpresa a quien migra: PathPatternParser sustituyó al matching de estilo Ant como opción por defecto, y un mapping de /orders ya no coincide con /orders/ a menos que configures el matching de la barra final o declares ambas rutas.

Controladores

Un controlador es un bean @Controller cuyos métodos se mapean con @GetMapping, @PostMapping y compañía. Spring resuelve por ti los parámetros del método; el valor de retorno elige la vista.

@Controller
@RequestMapping("/customers")
public class CustomerController {

    private final CustomerService customers;

    public CustomerController(CustomerService customers) {
        this.customers = customers;
    }

    @GetMapping
    public String list(@RequestParam(defaultValue = "") String q,
                       @RequestParam(defaultValue = "0") int page,
                       Model model) {
        model.addAttribute("customers", customers.search(q, page));
        model.addAttribute("query", q);
        return "customers/list";                 // logical view name
    }

    @GetMapping("/{id}")
    public String detail(@PathVariable long id, Model model) {
        model.addAttribute("customer", customers.get(id).orElseThrow(CustomerNotFoundException::new));
        return "customers/detail";
    }
}

Los tipos de parámetro más útiles: @PathVariable y @RequestParam con conversión automática (incluidos Optional<T>, enums y fechas mediante @DateTimeFormat); @RequestHeader y @CookieValue; Model o Map<String, Object> para exponer datos a la vista; HttpSession, Principal y Locale; HttpServletRequest y HttpServletResponse cuando necesitas la API en bruto; y @ModelAttribute para los objetos de formulario. Devuelve un String para un nombre de vista, un ModelAndView si quieres ambas cosas en un solo objeto, o void cuando el método escribe la respuesta por sí mismo.

Usa @RequestMapping a nivel de clase solo para un prefijo común. En los métodos, prefiere las anotaciones específicas de cada método HTTP: un @RequestMapping("/x") a secas acepta todos los métodos, y eso rara vez es lo que se pretende.

Formularios: binding y validación

El ciclo completo de un formulario es donde MVC demuestra lo que vale. El objeto de formulario es una clase corriente —un record sirve desde la 6.1, que añadió el binding por constructor para @ModelAttribute— anotada con restricciones de Jakarta Bean Validation:

public record RegistrationForm(
        @NotBlank @Size(max = 80) String name,
        @NotBlank @Email String email,
        @NotBlank @Size(min = 12, message = "{password.length}") String password,
        @AssertTrue(message = "{terms.required}") boolean acceptTerms) {
}
@Controller
@RequestMapping("/register")
public class RegistrationController {

    private final RegistrationService registrations;

    public RegistrationController(RegistrationService registrations) {
        this.registrations = registrations;
    }

    @GetMapping
    public String form(Model model) {
        model.addAttribute("form", new RegistrationForm("", "", "", false));
        return "register";
    }

    @PostMapping
    public String submit(@Valid @ModelAttribute("form") RegistrationForm form,
                         BindingResult binding,                 // must directly follow the form
                         RedirectAttributes redirect) {
        if (!binding.hasErrors() && registrations.emailTaken(form.email())) {
            binding.rejectValue("email", "email.taken");       // programmatic error, resolved via MessageSource
        }
        if (binding.hasErrors()) {
            return "register";                                 // re-render with the form and errors in the model
        }
        registrations.register(form);
        redirect.addFlashAttribute("message", "Welcome, " + form.name());
        return "redirect:/";                                   // Post/Redirect/Get
    }
}

El binding convierte primero los parámetros de la petición a los campos del formulario y registra los errores de incompatibilidad de tipos; después, gracias a @Valid, el proveedor de Bean Validation (Hibernate Validator en casi cualquier proyecto) comprueba las restricciones y registra las violaciones. Ambos acaban en BindingResult. Al devolver la vista del formulario, esta se vuelve a renderizar con los valores enviados y los mensajes: th:field y th:errors de Thymeleaf, igual que las etiquetas de formulario JSP, los leen del modelo automáticamente.

Hay dos detalles que se pasan por alto con facilidad. Un fallo de @Valid solo se convierte en un re-renderizado limpio porque BindingResult va inmediatamente después del parámetro del formulario; muévelo y obtendrás un 400. Además, el patrón Post/Redirect/Get —responder a un POST correcto con una redirección— evita que el navegador reenvíe el formulario al recargar. Los datos para la página que sigue a la redirección viajan en flash attributes, que se guardan en la sesión durante exactamente una petición y luego se eliminan; los atributos del modelo normales, en una redirección, se convierten en parámetros de la query string, algo que casi nunca quieres para un mensaje.

Los métodos @InitBinder personalizan el binding de un controlador concreto: registrar un editor o un formatter propio o, y esto es importante, restringir los campos enlazables con binder.setAllowedFields(...) para que una petición no pueda asignar propiedades que no pusiste en el formulario. Usar un objeto de formulario dedicado en lugar de una entidad JPA como @ModelAttribute consigue la misma protección con menos esfuerzo y es el hábito más robusto.

Interceptores y aspectos ligados a la petición

Un HandlerInterceptor ve todas las peticiones que se mapean a un handler y es la herramienta adecuada para aspectos que necesitan conocer el controlador pero no forman parte de la lógica de negocio:

public class RequestTimingInterceptor implements HandlerInterceptor {

    private static final String START = RequestTimingInterceptor.class.getName() + ".start";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        request.setAttribute(START, System.nanoTime());
        return true;                                            // false would end the request here
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) {
        long millis = (System.nanoTime() - (long) request.getAttribute(START)) / 1_000_000;
        if (handler instanceof HandlerMethod hm) {
            Metrics.record(hm.getBeanType().getSimpleName() + "." + hm.getMethod().getName(), millis, ex != null);
        }
    }
}

Elige un interceptor en vez de un Filter de servlet cuando necesites el handler resuelto o el modelo; elige un filtro cuando el aspecto deba aplicarse antes de que Spring intervenga siquiera: codificación, seguridad, compresión. Los interceptores no pueden ver el cuerpo de la respuesta de un método @ResponseBody, que ya se ha escrito cuando se ejecuta postHandle.

Gestión de excepciones para vistas

Una clase @ControllerAdvice aloja métodos @ExceptionHandler compartidos por todos los controladores. En una aplicación renderizada en el servidor, el handler suele elegir una vista de error y un código de estado:

@ControllerAdvice
public class WebExceptionHandler {

    @ExceptionHandler(CustomerNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public String notFound(CustomerNotFoundException ex, Model model) {
        model.addAttribute("id", ex.id());
        return "errors/not-found";
    }

    @ExceptionHandler(Exception.class)
    public ModelAndView unexpected(Exception ex, HttpServletRequest request) {
        var mav = new ModelAndView("errors/generic", HttpStatus.INTERNAL_SERVER_ERROR);
        mav.addObject("path", request.getRequestURI());
        return mav;
    }
}

Los métodos @ExceptionHandler locales de un controlador tienen prioridad sobre el advice, y los tipos de excepción más específicos ganan a los más generales, sea cual sea el orden de declaración. Todo lo que escapa a todos los resolvers llega a la configuración de páginas de error del contenedor de servlets; configura una, porque la página por defecto del contenedor filtra stack traces.

Errores comunes

Controladores obesos. Un controlador que consulta repositorios, aplica reglas y formatea resultados no se puede probar sin HTTP. Los controladores traducen entre HTTP y una llamada a un servicio; la lógica vive en el servicio.

Enlazar directamente entidades. Una petición puede rellenar cualquier propiedad modificable, incluidas las que nunca mostraste: un flag admin, un id. Usa un objeto de formulario que contenga solo los campos previstos.

Olvidarse de Post/Redirect/Get. Si renderizas una página directamente desde un POST, al recargar el navegador se reenvía el formulario y el usuario crea dos pedidos.

Estado en campos del controlador. Los controladores son singletons compartidos por todos los threads.

Añadir @EnableWebMvc en una aplicación Spring Boot. Desactiva la autoconfiguración de MVC de Boot, y funcionalidades como el mapeo de recursos estáticos, los message converters y la página de error dejan de comportarse como indica la documentación.

Ignorar el cambio de la barra final. Tras migrar a Spring 6, los enlaces a /orders/ devuelven 404. Corrige los enlaces o configura mappings explícitos para ambas formas.

Ideas clave

  • DispatcherServlet es un front controller que delega en estrategias: handler mappings, adapters y argument resolvers, view resolvers, exception resolvers; todas sustituibles.
  • Arranca con un WebApplicationInitializer (o con Boot) y configura mediante WebMvcConfigurer; el contexto raíz contiene los beans compartidos y el contexto del servlet, los beans web.
  • Los controladores mapean peticiones con anotaciones específicas de cada método HTTP, reciben parámetros ya resueltos y devuelven un nombre de vista junto con un modelo.
  • Gestionar un formulario es @Valid @ModelAttribute seguido inmediatamente de BindingResult, un re-renderizado si hay errores y una redirección con flash attributes si todo va bien.
  • Los interceptores se ocupan de los aspectos transversales que necesitan conocer el handler; @ControllerAdvice centraliza la correspondencia entre excepciones y vistas.
  • Spring 6 subió el mínimo a Jakarta Servlet 6.0, convirtió PathPatternParser en la opción por defecto y dejó de hacer coincidir las barras finales.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Qué diferencia hay entre el contexto raíz y el contexto del servlet?
El contexto raíz contiene los beans compartidos por toda la aplicación web —servicios, repositorios, el data source— y lo crea ContextLoaderListener. Cada DispatcherServlet tiene su propio contexto hijo con los beans web: controladores, view resolvers, interceptores. El hijo ve los beans del raíz, pero no al revés. Con un único DispatcherServlet puedes ponerlo todo en un solo contexto; la separación importa cuando varios servlets o filtros necesitan los beans compartidos.
¿Por qué BindingResult debe ir justo después del parámetro @ModelAttribute?
Spring enlaza y valida cada atributo del modelo en el orden de los parámetros y, si el siguiente parámetro es un BindingResult (o un Errors), guarda ahí el resultado en lugar de lanzar una excepción. Si hay cualquier otro parámetro en medio, los errores de binding no tienen dónde ir y Spring lanza MethodArgumentNotValidException, que acaba en una respuesta 400 en vez de tu formulario con sus mensajes de error.
¿Un controlador es un singleton? ¿Es eso seguro?
Sí, como cualquier bean de Spring por defecto, y sí es seguro mientras no tenga campos mutables por petición. Los datos de la petición llegan por los parámetros del método, y todo lo que deba sobrevivir entre peticiones va en la sesión, en un bean con scope request o en una base de datos; nunca en un campo del controlador.
¿Spring MVC sigue siendo compatible con JSP?
Sí, mediante InternalResourceViewResolver y la librería de etiquetas de formulario de Spring, y muchas aplicaciones existentes lo usan. Los proyectos nuevos renderizados en el servidor suelen elegir Thymeleaf, cuyas plantillas son HTML válido que se previsualiza sin servidor y que se integra con el modelo de Spring, los mensajes de validación y el dialecto de seguridad a través del módulo thymeleaf-spring6.