Módulo 21: Aplicaciones web con Spring MVC

Objetivos del módulo

  • Entender el patrón Modelo-Vista-Controlador aplicado a una aplicación web con páginas generadas en el servidor
  • Escribir controladores @Controller que preparan un Model y devuelven el nombre de una vista
  • Dominar la sintaxis de Thymeleaf: th:text, th:each, th:if, th:href con @{...}, th:object/th:field y los objetos de utilidad
  • Reutilizar la estructura de las páginas con fragmentos y un layout común
  • Servir recursos estáticos y usar Bootstrap 5 por CDN o con WebJars
  • Construir listados con búsqueda y paginación
  • Crear formularios de alta y edición con validación y mensajes de error
  • Aplicar el patrón Post/Redirect/Get con mensajes flash (RedirectAttributes)
  • Subir ficheros, internacionalizar la aplicación (messages.properties) y personalizar las páginas de error 404 y 500
  • Completar el CRUD web de empleados y departamentos de EMPRESA

1. El patrón MVC en la web

En el módulo 20 construiste una API REST: el servidor devuelve JSON y otro programa (un frontend en JavaScript, una aplicación móvil) se encarga de pintarlo. La otra gran forma de hacer aplicaciones web es que el servidor genere el HTML completo de cada página. Es lo que hacen muchas aplicaciones de gestión, backoffices e intranets, y lo que vamos a añadir a empresa-app.

📘 Concepto: El patrón Modelo-Vista-Controlador (MVC) separa tres responsabilidades:

  • Modelo: los datos que se van a mostrar (en Spring, un mapa de atributos llamado Model, que rellenamos con lo que devuelven los servicios).
  • Vista: la plantilla que convierte esos datos en HTML (una página Thymeleaf).
  • Controlador: recibe la petición, pide los datos a la capa de servicio, los pone en el modelo y elige qué vista mostrar.

Flujo MVC: el navegador pide /empleados, el DispatcherServlet llama al controlador, este rellena el Model con datos del servicio y devuelve el nombre de vista, el ViewResolver localiza la plantilla Thymeleaf y se devuelve el HTML Figura 1. Modelo-Vista-Controlador en una aplicación web con Spring MVC.

  API REST (módulo 20) Web MVC con Thymeleaf (este módulo)
Anotación @RestController @Controller
El método devuelve Un objeto → JSON Un String con el nombre de la vista
Genera el HTML El cliente (JavaScript) El servidor (Thymeleaf)
Navegación La gestiona el frontend Enlaces y formularios HTML clásicos
Ventajas Un mismo backend para web, móvil, otros servicios Sencillez, SEO, funciona sin JavaScript, un solo proyecto

Ambos estilos conviven sin problema en empresa-app: los controladores REST están en web.api y los de páginas en web.mvc, y los dos usan los mismos servicios.

Thymeleaf

Thymeleaf es el motor de plantillas recomendado por Spring. Sus plantillas son HTML válido con atributos extra th:*: si abres el fichero directamente en el navegador, se ve como un prototipo con datos de ejemplo; al procesarlo en el servidor, Thymeleaf sustituye esos datos por los reales. Es lo que llaman natural templating.

Con spring-boot-starter-thymeleaf (lo elegimos en Initializr en el módulo 18), Boot lo configura todo:

Convención Valor por defecto
Carpeta de plantillas src/main/resources/templates/
Extensión .html
Vista "empleados/lista" templates/empleados/lista.html
Caché de plantillas Activada (DevTools la desactiva en desarrollo para ver los cambios al recargar)
Recursos estáticos src/main/resources/static/ → servidos desde /

2. Controladores con @Controller y Model

El controlador más sencillo de empresa-app es el de la página de inicio:

@Controller
public class InicioController {

    private final EmpleadoService empleados;
    private final DepartamentoService departamentos;
    private final EmpresaProperties props;

    public InicioController(EmpleadoService empleados, DepartamentoService departamentos,
                            EmpresaProperties props) {
        this.empleados = empleados;
        this.departamentos = departamentos;
        this.props = props;
    }

    @GetMapping("/")
    public String inicio(Model model) {
        model.addAttribute("empresa", props.nombre());
        model.addAttribute("numEmpleados", empleados.listar(Pageable.ofSize(1)).getTotalElements());
        model.addAttribute("numDepartamentos", departamentos.listar().size());
        return "inicio";                    // → templates/inicio.html
    }
}

Página de inicio de empresa-app con la bienvenida y las tarjetas de empleados (24) y departamentos (7) Figura 2. Página de inicio de empresa-app. Captura real.

Elemento Para qué
@Controller Componente web cuyos métodos devuelven nombres de vista
Model model Parámetro que Spring inyecta: los atributos que añadas estarán disponibles en la plantilla
return "inicio" Nombre lógico de la vista; el ViewResolver de Thymeleaf lo convierte en templates/inicio.html
return "redirect:/empleados" En lugar de una vista, envía al navegador una redirección (302)
return "forward:/otra" Reenvía internamente la petición a otra ruta (poco habitual)
@ModelAttribute en un método Ese método se ejecuta antes de cada petición del controlador y añade su resultado al modelo
@ResponseBody en un método Ese método devuelve datos, no una vista (lo usaremos para la foto del empleado)

⚠️ Importante: Si un @Controller devuelve un String que no corresponde a ninguna plantilla, verás un error 500 Error resolving template [empleados/lsta]: revisa el nombre y la carpeta. Y si te equivocas y pones @RestController, el navegador mostrará literalmente el texto empleados/lista.


3. La sintaxis de Thymeleaf

Toda plantilla declara el espacio de nombres th (no es obligatorio, pero ayuda al IDE):

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org">

Las cinco expresiones

Expresión Nombre Ejemplo Resultado
${...} Variable ${empleado.nombre} Atributo del modelo (con navegación por getters)
*{...} Selección *{salario} dentro de th:object="${empleado}" Propiedad del objeto seleccionado
#{...} Mensaje #{empleados.titulo} Texto de messages.properties en el idioma del usuario
@{...} Enlace (URL) @{/empleados/{id}(id=${e.id})} /empleados/120 (con el context path si lo hay)
~{...} Fragmento ~{fragmentos/layout :: layout(...)} Un trozo de otra plantilla

Dentro de ${...} se usa SpEL (Spring Expression Language), el mismo del módulo 17: operadores (+, ==, and, !), llamadas a métodos, el operador Elvis ?: (valor por defecto) y la navegación segura ?. (no falla con null).

Atributos principales

Atributo Para qué Ejemplo
th:text Sustituye el contenido del elemento (escapando HTML) <td th:text="${e.oficio}">DIRECTOR</td>
th:utext Igual, sin escapar (¡peligro de XSS!) Solo con HTML de confianza
th:each Repite el elemento por cada elemento de una colección <tr th:each="e : ${pagina.content}">
th:if / th:unless Muestra el elemento si se cumple / no se cumple <span th:if="${p.stock > 0}">
th:switch / th:case Varias alternativas Por estado de pedido
th:href, th:src, th:action URL con @{...} th:href="@{/empleados/nuevo}"
th:value, th:selected, th:checked Valores de formulario th:selected="${d.id == dept}"
th:classappend Añade clases CSS según una condición th:classappend="${activo} ? 'active'"
th:attr, th:title, th:placeholder… Cualquier atributo HTML th:placeholder="#{empleados.buscar}"
th:with Declara una variable local th:with="total=${#lists.size(lista)}"
th:object / th:field / th:errors Formularios (sección 7) th:field="*{nombre}"
th:fragment / th:replace / th:insert Fragmentos (sección 4) th:replace="~{fragmentos/paginacion :: paginacion(...)}"

⚠️ Importante: th:text escapa los caracteres especiales: si un empleado se llama <script>alert(1)</script>, se mostrará como texto y no se ejecutará. Usa siempre th:text; th:utext abre la puerta a ataques XSS si el contenido lo escribe un usuario.

Un listado con th:each

Del listado de departamentos (templates/departamentos/lista.html):

<tbody>
<!-- th:each con variable de estado (est.count, est.odd...) -->
<tr th:each="d, est : ${departamentos}">
    <td th:text="${d.id}">10</td>
    <td><a th:href="@{/departamentos/{id}(id=${d.id})}" th:text="${d.nombre}">DIRECCIÓN</a></td>
    <td th:text="${d.localidad}">Madrid</td>
    <td class="text-end"
        th:text="${d.presupuesto} ? ${#numbers.formatDecimal(d.presupuesto, 1, 'DEFAULT', 2, 'DEFAULT') + ' €'} : '—'">500.000,00 €</td>
    <td class="text-center">
        <span class="badge rounded-pill"
              th:classappend="${d.numEmpleados == 0} ? 'text-bg-warning' : 'text-bg-primary'"
              th:text="${d.numEmpleados}">1</span>
    </td>
    ...
</tr>
</tbody>

Los textos que hay dentro de las etiquetas (DIRECCIÓN, Madrid…) son los datos de prototipo: Thymeleaf los sustituye. La variable de estado (est) ofrece index, count, size, first, last, odd y even.

Enlaces con @{…}

<a th:href="@{/empleados}">                                    <!-- /empleados -->
<a th:href="@{/empleados/{id}(id=${e.id})}">                   <!-- /empleados/120 -->
<a th:href="@{/empleados/{id}/editar(id=${e.id})}">            <!-- /empleados/120/editar -->
<a th:href="@{/empleados(page=${i}, q=${q}, dept=${dept})}">   <!-- /empleados?page=1&q=ez&dept=30 -->
<link rel="stylesheet" th:href="@{/css/empresa.css}">          <!-- recurso estático -->

Los parámetros que no aparecen como {variable} en la ruta se añaden como parámetros de consulta y se codifican automáticamente (espacios, tildes…).

Objetos de utilidad

Objeto Ejemplos
#numbers #numbers.formatDecimal(e.salario, 1, 'DEFAULT', 2, 'DEFAULT') → 2.600,00 en español, 2,600.00 en inglés; #numbers.sequence(0, 4)
#temporals #temporals.format(e.fechaAlta, 'dd/MM/yyyy') → 05/05/2014 (fechas java.time)
#strings #strings.toUpperCase(...), #strings.abbreviate(texto, 30), #strings.isEmpty(...)
#lists #lists.size(...), #lists.isEmpty(...), #lists.contains(...)
#aggregates #aggregates.sum(lista.![precio])
#fields Errores de formulario: #fields.hasErrors('nombre'), #fields.globalErrors()

💡 Consejo: Para importes en euros usa #numbers.formatDecimal(...) + ' €' y no #numbers.formatCurrency(...). Este último usa la moneda del idioma del usuario: al cambiar la web a inglés (Locale en, sin país) los salarios aparecían como ¤2,600.00, con el símbolo genérico de moneda. Nos pasó al preparar este módulo.


4. Fragmentos y layouts

Todas las páginas comparten cabecera, menú, mensajes y pie. Copiarlos en cada plantilla sería un error de mantenimiento. Thymeleaf permite definir fragmentos reutilizables con th:fragment e incluirlos con th:replace (sustituye la etiqueta anfitriona) o th:insert (inserta dentro de ella).

Un fragmento parametrizado: la paginación

templates/fragmentos/paginacion.html:

<!--/* Fragmento reutilizable: recibe un Page<?> y la URL base del listado */-->
<nav th:fragment="paginacion(pagina, url)" th:if="${pagina.totalPages > 1}" aria-label="Paginación">
    <ul class="pagination justify-content-center">
        <li class="page-item" th:classappend="${pagina.first} ? 'disabled'">
            <a class="page-link" th:href="@{${url}(page=${pagina.number - 1}, q=${q}, dept=${dept})}">&laquo;</a>
        </li>
        <li class="page-item" th:each="i : ${#numbers.sequence(0, pagina.totalPages - 1)}"
            th:classappend="${i == pagina.number} ? 'active'">
            <a class="page-link" th:href="@{${url}(page=${i}, q=${q}, dept=${dept})}" th:text="${i + 1}">1</a>
        </li>
        <li class="page-item" th:classappend="${pagina.last} ? 'disabled'">
            <a class="page-link" th:href="@{${url}(page=${pagina.number + 1}, q=${q}, dept=${dept})}">&raquo;</a>
        </li>
    </ul>
    <p class="text-center text-secondary small"
       th:text="#{paginacion.resumen(${pagina.number + 1}, ${pagina.totalPages}, ${pagina.totalElements})}">
        Página 1 de 3 (24 registros)</p>
</nav>

Se usa así desde el listado de empleados:

<div class="mt-3" th:replace="~{fragmentos/paginacion :: paginacion(${pagina}, '/empleados')}"></div>

💡 Consejo: Los comentarios <!--/* ... */--> son comentarios de Thymeleaf: se eliminan al procesar la plantilla y no llegan al navegador. Los comentarios HTML normales (<!-- ... -->) sí se envían.

El layout común

Para el esqueleto de la página usamos un fragmento que recibe otros fragmentos como parámetros: el título, la sección activa del menú y el contenido. Es la técnica nativa de Thymeleaf 3, sin dependencias adicionales:

Una página pasa su title y su section al fragmento layout, que los coloca en la cabecera y en el hueco de contenido junto al menú, los mensajes flash y el pie Figura 3. Layout común con fragmentos parametrizados.

templates/fragmentos/layout.html (resumido):

<!DOCTYPE html>
<!--/* Plantilla base: la usan todas las páginas pasándole su título, la sección activa y su contenido */-->
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      th:fragment="layout(titulo, activo, contenido)">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title th:replace="${titulo}">EMPRESA</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
    <link rel="stylesheet" th:href="@{/css/empresa.css}">
</head>
<body class="d-flex flex-column min-vh-100 bg-body-tertiary">

<nav class="navbar navbar-expand-md navbar-dark barra-empresa">
    <div class="container">
        <a class="navbar-brand fw-semibold" th:href="@{/}">
            <i class="bi bi-buildings"></i> <span th:text="#{app.nombre}">EMPRESA</span>
        </a>
        ...
        <ul class="navbar-nav me-auto">
            <li class="nav-item">
                <a class="nav-link" th:classappend="${activo == 'empleados'} ? 'active'"
                   th:href="@{/empleados}" th:text="#{menu.empleados}">Empleados</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" th:classappend="${activo == 'departamentos'} ? 'active'"
                   th:href="@{/departamentos}" th:text="#{menu.departamentos}">Departamentos</a>
            </li>
            ...
        </ul>
        <span class="navbar-text small">
            <a class="link-light" th:href="@{''(lang='es')}">ES</a> |
            <a class="link-light" th:href="@{''(lang='en')}">EN</a>
        </span>
    </div>
</nav>

<main class="container py-4 flex-grow-1">
    <!-- Mensajes flash (sobreviven a una redirección) -->
    <div th:if="${exito}" class="alert alert-success alert-dismissible fade show" role="alert">
        <i class="bi bi-check-circle"></i> <span th:text="${exito}">Operación correcta</span>
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    </div>
    <div th:if="${fallo}" class="alert alert-danger alert-dismissible fade show" role="alert">
        <i class="bi bi-exclamation-triangle"></i> <span th:text="${fallo}">Error</span>
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    </div>

    <th:block th:replace="${contenido}">Contenido de la página</th:block>
</main>

<footer class="py-3 border-top bg-white">...</footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Cada página se limita a pasarle sus trozos con selectores de fragmento (~{::title} = «el elemento <title> de esta misma plantilla»):

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      th:replace="~{fragmentos/layout :: layout(~{::title}, 'empleados', ~{::section})}">
<head>
    <title th:text="#{empleados.titulo}">Empleados</title>
</head>
<body>
<section>
    ... contenido propio de la página ...
</section>
</body>
</html>

<th:block> es un elemento de Thymeleaf que desaparece al procesar la plantilla: sirve de contenedor sin dejar etiquetas en el HTML final.

💡 Consejo: Existe también el Thymeleaf Layout Dialect (nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect, cuya versión gestiona Spring Boot) con atributos como layout:decorate y layout:fragment. Es muy popular, pero la técnica de fragmentos parametrizados que usamos no necesita ninguna dependencia extra.


5. Recursos estáticos y Bootstrap 5

Todo lo que pongas en src/main/resources/static/ se sirve tal cual desde la raíz de la aplicación:

src/main/resources/static/css/empresa.css   →   http://localhost:8080/css/empresa.css
src/main/resources/static/img/logo.png      →   http://localhost:8080/img/logo.png

Nuestro CSS propio solo personaliza el color de la barra (con la paleta índigo del curso) y el enlace activo:

/* Estilos propios de empresa-app (se sirven desde /css/empresa.css) */
.barra-empresa {
    background-color: #283593;
}
.barra-empresa .nav-link.active {
    font-weight: 600;
    border-bottom: 2px solid #ff7043;
}

Bootstrap por CDN o con WebJars

Para el aspecto general usamos Bootstrap 5.3.8 y Bootstrap Icons 1.13, cargados desde la CDN jsDelivr (lo que ya conoces del Curso de HTML y CSS). La alternativa, útil si la aplicación debe funcionar sin Internet o quieres que Maven gestione las versiones, son los WebJars: librerías web empaquetadas como dependencias Maven.

<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>bootstrap</artifactId>
    <version>5.3.8</version>
</dependency>
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>webjars-locator-lite</artifactId>   <!-- versión gestionada por Spring Boot -->
</dependency>
<!-- Con el locator no hace falta escribir la versión en la URL -->
<link rel="stylesheet" th:href="@{/webjars/bootstrap/css/bootstrap.min.css}">
<script th:src="@{/webjars/bootstrap/js/bootstrap.bundle.min.js}"></script>
  CDN WebJars
Configuración Ninguna Dos dependencias
Funciona sin Internet No Sí
Versión controlada por La URL en la plantilla El pom.xml
Caché del navegador compartida con otras webs No (los navegadores modernos aíslan la caché por sitio) No
Tamaño del JAR Igual Algo mayor

6. Listados con búsqueda y paginación

El listado de empleados combina lo que ya sabes: la búsqueda dinámica con Specifications del módulo 19 y la paginación con Pageable.

@Controller
@RequestMapping("/empleados")
public class EmpleadoController {

    private final EmpleadoService empleados;
    private final DepartamentoService departamentos;
    private final FotoService fotos;

    public EmpleadoController(EmpleadoService empleados, DepartamentoService departamentos, FotoService fotos) {
        this.empleados = empleados;
        this.departamentos = departamentos;
        this.fotos = fotos;
    }

    // ---------- Listado con búsqueda y paginación ----------
    @GetMapping
    public String listar(@RequestParam(required = false) String q,
                         @RequestParam(required = false) Integer dept,
                         @PageableDefault(size = 8, sort = "apellidos") Pageable pageable,
                         Model model) {
        Page<Empleado> pagina = empleados.buscar(q, null, dept, null, pageable);
        model.addAttribute("pagina", pagina);
        model.addAttribute("q", q);
        model.addAttribute("dept", dept);
        model.addAttribute("departamentos", departamentos.listar());
        return "empleados/lista";                  // templates/empleados/lista.html
    }
    ...
}

La plantilla (templates/empleados/lista.html, la parte central):

<!-- Búsqueda: formulario GET, los parámetros viajan en la URL -->
<form class="row g-2 mb-3" th:action="@{/empleados}" method="get">
    <div class="col-md-6">
        <input type="search" name="q" class="form-control" th:value="${q}"
               th:placeholder="#{empleados.buscar}" placeholder="Buscar por nombre o apellidos">
    </div>
    <div class="col-md-4">
        <select name="dept" class="form-select">
            <option value="" th:text="#{empleados.todos}">Todos los departamentos</option>
            <option th:each="d : ${departamentos}" th:value="${d.id}" th:text="${d.nombre}"
                    th:selected="${d.id == dept}">VENTAS</option>
        </select>
    </div>
    <div class="col-md-2 d-grid">
        <button class="btn btn-outline-primary"><i class="bi bi-search"></i> <span th:text="#{boton.buscar}">Buscar</span></button>
    </div>
</form>

<div class="card">
    <table class="table table-hover align-middle mb-0">
        <thead class="table-light">
        <tr>
            <th><a th:href="@{/empleados(sort='id', q=${q}, dept=${dept})}">#</a></th>
            <th><a th:href="@{/empleados(sort='apellidos', q=${q}, dept=${dept})}" th:text="#{empleado.nombre}">Nombre</a></th>
            <th th:text="#{empleado.oficio}">Oficio</th>
            <th th:text="#{empleado.departamento}">Departamento</th>
            <th class="text-end">
                <a th:href="@{/empleados(sort='salario,desc', q=${q}, dept=${dept})}" th:text="#{empleado.salario}">Salario</a>
            </th>
            <th></th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="e : ${pagina.content}">
            <td th:text="${e.id}">100</td>
            <td>
                <a th:href="@{/empleados/{id}(id=${e.id})}" th:text="${e.apellidos + ', ' + e.nombre}">García López, Ana</a>
            </td>
            <td><span class="badge text-bg-secondary" th:text="${e.oficio}">DIRECTOR</span></td>
            <td th:text="${e.departamento?.nombre} ?: #{empleado.sinDepartamento}">DIRECCIÓN</td>
            <td class="text-end" th:text="${#numbers.formatDecimal(e.salario, 1, 'DEFAULT', 2, 'DEFAULT') + ' €'}">6.500,00 €</td>
            <td class="text-end">
                <a class="btn btn-sm btn-outline-secondary" th:href="@{/empleados/{id}/editar(id=${e.id})}"
                   th:title="#{boton.editar}"><i class="bi bi-pencil"></i></a>
            </td>
        </tr>
        <tr th:if="${pagina.empty}">
            <td colspan="6" class="text-center text-secondary py-4" th:text="#{empleados.vacio}">No hay empleados</td>
        </tr>
        </tbody>
    </table>
</div>

<div class="mt-3" th:replace="~{fragmentos/paginacion :: paginacion(${pagina}, '/empleados')}"></div>

Listado de empleados con buscador, desplegable de departamentos, tabla con ocho empleados y paginación de 3 páginas con 24 registros Figura 4. Listado de empleados, página 1 de 3. Captura real.

Fíjate en ${e.departamento?.nombre} ?: #{empleado.sinDepartamento}: el becario Tomás Reyes (160) no tiene departamento, y la navegación segura ?. evita el error; el Elvis ?: pone el texto alternativo. Y como EmpleadoRepository sobrescribe findAll(Specification, Pageable) con @EntityGraph(attributePaths = {"departamento", "jefe"}), toda la página se carga con una sola consulta (más la del total), sin N+1.

Buscando «ez» en Informática (/empleados?q=ez&dept=30):

Resultado de buscar ez en el departamento de Informática: un empleado, Iván Castro Méndez Figura 5. Búsqueda combinada por texto y departamento. Captura real.

💡 Consejo: Usa GET para los formularios de búsqueda: los parámetros quedan en la URL, así que el usuario puede guardar la búsqueda en favoritos, compartirla o volver atrás con el navegador. Usa POST para todo lo que modifique datos.


7. Formularios de alta y edición con validación

El objeto de formulario

Igual que en la API no recibimos entidades, en la web usamos un objeto de formulario (form backing bean) con las reglas de validación. Para el enlace bidireccional con th:field necesita propiedades JavaBean (getters y setters), así que es una clase normal y no un record:

public class EmpleadoForm {

    private Integer id;                       // null = alta, con valor = edición

    @NotBlank(message = "{empleado.nombre.obligatorio}")
    @Size(max = 30, message = "{campo.max}")
    private String nombre;

    @NotBlank(message = "{empleado.apellidos.obligatorio}")
    @Size(max = 50, message = "{campo.max}")
    private String apellidos;

    @NotBlank(message = "{empleado.email.obligatorio}")
    @Email(message = "{empleado.email.formato}")
    @Size(max = 60, message = "{campo.max}")
    private String email;

    @Pattern(regexp = "^$|^[0-9]{9}$", message = "{empleado.telefono.formato}")
    private String telefono;

    @NotNull(message = "{empleado.fechaAlta.obligatoria}")
    @PastOrPresent(message = "{empleado.fechaAlta.futura}")
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate fechaAlta = LocalDate.now();

    @NotBlank(message = "{empleado.oficio.obligatorio}")
    @Size(max = 30, message = "{campo.max}")
    private String oficio;

    @NotNull(message = "{empleado.salario.obligatorio}")
    @DecimalMin(value = "900.00", message = "{empleado.salario.minimo}")
    @Digits(integer = 6, fraction = 2, message = "{empleado.salario.formato}")
    private BigDecimal salario;

    @PositiveOrZero(message = "{empleado.comision.negativa}")
    private BigDecimal comision;

    private Integer departamentoId;
    private Integer jefeId;

    /** Crea el formulario relleno con los datos de un empleado existente (edición). */
    public static EmpleadoForm desde(Empleado e) { ... }

    public boolean isNuevo() { return id == null; }

    // getters y setters
}
  • Los mensajes entre llaves ({empleado.nombre.obligatorio}) se buscan en messages.properties (sección 11), así quedan traducibles. Dentro del mensaje se pueden usar los atributos de la restricción: campo.max=No puede superar los {max} caracteres.
  • @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) hace que la fecha se lea y se escriba como 2026-10-01, el formato que usa <input type="date">.
  • El departamento y el jefe se representan por su id, que es lo que envía un <select>.

El controlador del formulario

// ---------- Formulario de alta y de edición ----------
@GetMapping("/nuevo")
public String nuevo(Model model) {
    model.addAttribute("empleado", new EmpleadoForm());
    return prepararFormulario(model);
}

@GetMapping("/{id}/editar")
public String editar(@PathVariable Integer id, Model model) {
    model.addAttribute("empleado", EmpleadoForm.desde(empleados.buscar(id)));
    return prepararFormulario(model);
}

@PostMapping("/guardar")
public String guardar(@Valid @ModelAttribute("empleado") EmpleadoForm form,
                      BindingResult errores, Model model, RedirectAttributes flash) {
    if (errores.hasErrors()) {
        return prepararFormulario(model);     // se vuelve a mostrar el formulario con los errores
    }
    Empleado guardado;
    try {
        guardado = empleados.guardar(form);
    } catch (ReglaNegocioException ex) {
        errores.reject("negocio", ex.getMessage());   // error global (no de un campo)
        return prepararFormulario(model);
    }
    flash.addFlashAttribute("exito", form.isNuevo()
            ? "Empleado " + guardado.getNombreCompleto() + " dado de alta"
            : "Cambios guardados");
    return "redirect:/empleados/" + guardado.getId();   // patrón PRG
}

private String prepararFormulario(Model model) {
    model.addAttribute("departamentos", departamentos.listar());
    model.addAttribute("jefes", empleados.listarTodos());
    return "empleados/formulario";
}

⚠️ Importante: El parámetro BindingResult debe ir justo detrás del objeto validado. Si no lo declaras, un error de validación provoca una excepción (y un 400) en lugar de dejarte volver a mostrar el formulario. Y el nombre del @ModelAttribute("empleado") debe coincidir con el th:object de la plantilla.

Enlace de datos: los campos del formulario se envían en el POST, Spring rellena EmpleadoForm, valida con Bean Validation y deja los errores en BindingResult con mensajes de messages.properties que th:errors muestra Figura 6. Enlace de datos y validación de un formulario.

La plantilla del formulario

<!-- th:object enlaza el formulario con el objeto "empleado" del modelo -->
<form th:action="@{/empleados/guardar}" th:object="${empleado}" method="post" novalidate
      class="card card-body">

    <!-- Errores globales (no asociados a un campo), p. ej. email duplicado -->
    <div class="alert alert-danger" th:if="${#fields.hasGlobalErrors()}">
        <p class="mb-0" th:each="err : ${#fields.globalErrors()}" th:text="${err}">Error global</p>
    </div>

    <input type="hidden" th:field="*{id}">

    <div class="row g-3">
        <div class="col-md-4">
            <label for="nombre" class="form-label" th:text="#{empleado.nombre}">Nombre</label>
            <input type="text" class="form-control" th:field="*{nombre}" th:errorclass="is-invalid">
            <div class="invalid-feedback" th:errors="*{nombre}">Error de nombre</div>
        </div>
        ...
        <div class="col-md-3">
            <label for="fechaAlta" class="form-label" th:text="#{empleado.fechaAlta}">Fecha de alta</label>
            <input type="date" class="form-control" th:field="*{fechaAlta}" th:errorclass="is-invalid">
            <div class="invalid-feedback" th:errors="*{fechaAlta}">Error de fecha</div>
        </div>
        ...
        <div class="col-md-6">
            <label for="departamentoId" class="form-label" th:text="#{empleado.departamento}">Departamento</label>
            <select class="form-select" th:field="*{departamentoId}">
                <option value="" th:text="#{empleado.sinDepartamento}">Sin departamento</option>
                <option th:each="d : ${departamentos}" th:value="${d.id}" th:text="${d.nombre}">VENTAS</option>
            </select>
        </div>
        ...
    </div>

    <div class="mt-4 d-flex gap-2">
        <button type="submit" class="btn btn-primary">
            <i class="bi bi-save"></i> <span th:text="#{boton.guardar}">Guardar</span>
        </button>
        <a class="btn btn-outline-secondary" th:href="@{/empleados}" th:text="#{boton.cancelar}">Cancelar</a>
    </div>
</form>
Atributo Qué hace
th:object="${empleado}" Selecciona el objeto del formulario; dentro, *{...} se refiere a sus propiedades
th:field="*{nombre}" Genera id="nombre", name="nombre" y value="..." (en <select>, marca la opción seleccionada; en checkbox, checked)
th:errorclass="is-invalid" Añade la clase CSS si ese campo tiene errores (Bootstrap pinta el borde rojo)
th:errors="*{nombre}" Muestra los mensajes de error del campo
#fields.hasGlobalErrors() / #fields.globalErrors() Errores que no son de un campo (errores.reject(...))
novalidate Desactiva la validación HTML5 del navegador para ver la del servidor (quítalo en producción si quieres ambas)

El formulario vacío de alta:

Formulario de nuevo empleado con los campos nombre, apellidos, email, teléfono, fecha de alta, oficio, salario, comisión, departamento y jefe Figura 7. Formulario de alta de un empleado. Captura real.

Y lo que ocurre al enviarlo con datos incorrectos (nombre vacío, email sin dominio, teléfono corto, fecha futura y salario por debajo del mínimo):

Formulario con errores de validación en rojo bajo los campos nombre, email, teléfono, fecha de alta y salario Figura 8. Errores de validación mostrados junto a cada campo. Captura real.

Al editar un empleado existente (/empleados/120/editar), el controlador rellena el mismo formulario con EmpleadoForm.desde(...) y los desplegables aparecen con el departamento y el jefe seleccionados:

Formulario de edición de Pablo Moreno Herrera con sus datos cargados, departamento Informática y jefa Marta Sánchez Gil Figura 9. El mismo formulario en modo edición. Captura real.

En la figura 8, los valores escritos se conservan (th:field los vuelve a pintar), el oficio se ha mantenido en mayúsculas por la clase CSS text-uppercase y cada mensaje sale de messages.properties.

💡 Consejo: Si el usuario escribe letras en el salario, el error no lo genera Bean Validation sino la conversión de tipos, y su mensaje se busca con la clave typeMismatch (por ejemplo typeMismatch.java.math.BigDecimal=Debe ser un número), que también hemos añadido a messages.properties.


8. El patrón Post/Redirect/Get y los mensajes flash

¿Por qué, si todo va bien, guardar devuelve "redirect:/empleados/" + id en lugar de mostrar directamente la vista de detalle?

Diagrama de secuencia PRG: POST /empleados/guardar, respuesta 302 con Location, GET /empleados/161 y respuesta 200 con el mensaje flash Figura 10. Patrón Post/Redirect/Get con mensaje flash.

📘 Concepto: Con el patrón PRG (Post/Redirect/Get), después de procesar un POST que modifica datos, el servidor responde con una redirección 302 a una página que se carga con GET. Así, si el usuario recarga (F5) o pulsa «Atrás», el navegador repite el GET (inofensivo) y no el POST (que daría de alta otro empleado).

El problema es que tras la redirección el modelo se pierde: es otra petición. Para pasar el mensaje «Empleado dado de alta» se usan los atributos flash:

flash.addFlashAttribute("exito", "Empleado " + guardado.getNombreCompleto() + " dado de alta");
return "redirect:/empleados/" + guardado.getId();
Método de RedirectAttributes Efecto
addFlashAttribute("exito", "...") Se guarda en la sesión solo hasta la siguiente petición, en la que aparece en el modelo y se borra
addAttribute("id", 5) Se añade como parámetro a la URL de la redirección (?id=5)

El layout muestra ${exito} y ${fallo} si existen. El resultado tras dar de alta a Lucas Pérez Sanz:

Detalle del nuevo empleado Lucas Pérez Sanz, código 161, con el mensaje verde Empleado Lucas Pérez Sanz dado de alta Figura 11. Detalle del empleado recién creado con el mensaje flash de éxito. Captura real.

⚠️ Importante: No llames error a un atributo flash. Spring Boot pone en el modelo de las páginas de error un atributo error con el texto del estado («Not Found»), y nuestro layout lo mostraba como si fuera un mensaje flash en la página 404. Por eso el atributo de los mensajes de fallo se llama fallo.


9. Detalle y borrado

La página de detalle (templates/empleados/detalle.html) usa th:object para no repetir ${empleado.} en cada línea y #temporals para la fecha:

<section th:object="${empleado}">
    ...
    <dl class="row mb-0">
        <dt class="col-sm-4" th:text="#{empleado.id}">Código</dt>
        <dd class="col-sm-8" th:text="*{id}">100</dd>
        <dt class="col-sm-4" th:text="#{empleado.email}">Email</dt>
        <dd class="col-sm-8"><a th:href="'mailto:' + *{email}" th:text="*{email}">ana@empresa.es</a></dd>
        <dt class="col-sm-4" th:text="#{empleado.fechaAlta}">Fecha de alta</dt>
        <dd class="col-sm-8" th:text="*{#temporals.format(fechaAlta, 'dd/MM/yyyy')}">15/01/2010</dd>
        ...
        <dt class="col-sm-4" th:text="#{empleado.departamento}">Departamento</dt>
        <dd class="col-sm-8">
            <a th:if="*{departamento}" th:href="@{/departamentos/{id}(id=*{departamento.id})}"
               th:text="*{departamento.nombre + ' (' + departamento.localidad + ')'}">DIRECCIÓN</a>
            <span th:unless="*{departamento}" th:text="#{empleado.sinDepartamento}">Sin departamento</span>
        </dd>
        ...
    </dl>
    ...
    <!-- Borrar SIEMPRE con POST, nunca con un enlace GET -->
    <form th:action="@{/empleados/{id}/borrar(id=*{id})}" method="post"
          onsubmit="return confirm('¿Seguro que quieres borrar este empleado?');">
        <button class="btn btn-outline-danger">
            <i class="bi bi-trash"></i> <span th:text="#{boton.borrar}">Borrar</span>
        </button>
    </form>
</section>

El controlador intenta el borrado y comunica el resultado con un mensaje flash. Si el servicio lo impide (el empleado es jefe de otros o tiene clientes o pedidos), vuelve al detalle con el error:

@PostMapping("/{id}/borrar")
public String borrar(@PathVariable Integer id, RedirectAttributes flash) {
    try {
        empleados.borrar(id);
        flash.addFlashAttribute("exito", "Empleado " + id + " borrado");
    } catch (ReglaNegocioException ex) {
        flash.addFlashAttribute("fallo", ex.getMessage());
        return "redirect:/empleados/" + id;
    }
    return "redirect:/empleados";
}

Detalle de Luis Martínez Ruiz con el mensaje rojo Luis Martínez Ruiz es jefe de otros empleados tras intentar borrarlo Figura 12. Intento de borrar a un jefe: el servicio lo impide y el mensaje flash lo explica. Captura real.

⚠️ Importante: Nunca borres con un enlace (<a href="/empleados/5/borrar">): los navegadores, los crawlers y las extensiones de precarga siguen enlaces GET automáticamente. Las operaciones que modifican datos van siempre por POST (en el módulo 22, Spring Security exigirá además un token CSRF en esos formularios).

Como EmpleadoService.buscar lanza RecursoNoEncontradoException (anotada con @ResponseStatus(HttpStatus.NOT_FOUND)), visitar /empleados/999 muestra la página 404 personalizada (sección 12).


10. Subida de ficheros

Vamos a permitir subir una foto de cada empleado. Las fotos no se guardan en la base de datos de EMPRESA (no hay columna para ello), sino en una carpeta del disco.

  1. El formulario debe usar method="post" y enctype="multipart/form-data":
<form th:action="@{/empleados/{id}/foto(id=*{id})}" method="post" enctype="multipart/form-data">
    <input type="file" name="foto" accept="image/png,image/jpeg" class="form-control form-control-sm mb-2">
    <button class="btn btn-sm btn-outline-primary" th:text="#{boton.subir}">Subir</button>
</form>
  1. El controlador recibe un MultipartFile:
@PostMapping("/{id}/foto")
public String subirFoto(@PathVariable Integer id, @RequestParam("foto") MultipartFile foto,
                        RedirectAttributes flash) {
    empleados.buscar(id);
    try {
        fotos.guardar(id, foto);
        flash.addFlashAttribute("exito", "Foto actualizada");
    } catch (ReglaNegocioException ex) {
        flash.addFlashAttribute("fallo", ex.getMessage());
    }
    return "redirect:/empleados/" + id;
}

@GetMapping("/{id}/foto")
@ResponseBody
public ResponseEntity<byte[]> foto(@PathVariable Integer id) {
    return fotos.leer(id)
            .map(bytes -> ResponseEntity.ok().contentType(MediaType.IMAGE_JPEG).body(bytes))
            .orElse(ResponseEntity.notFound().build());
}
  1. Un pequeño servicio guarda el fichero comprobando su tipo y decidiendo él el nombre:
@Service
public class FotoService {

    private static final Set<String> TIPOS = Set.of("image/png", "image/jpeg");
    private final Path directorio;

    public FotoService(@Value("${empresa.directorio-fotos:fotos}") Path directorio) throws IOException {
        this.directorio = Files.createDirectories(directorio);
    }

    public void guardar(Integer empleadoId, MultipartFile fichero) {
        if (fichero.isEmpty()) {
            throw new ReglaNegocioException("No has seleccionado ningún fichero");
        }
        if (!TIPOS.contains(fichero.getContentType())) {
            throw new ReglaNegocioException("Solo se admiten imágenes PNG o JPEG");
        }
        // El nombre lo decide el servidor: nunca uses el nombre original sin limpiar
        Path destino = directorio.resolve("empleado-" + empleadoId + ".img");
        try (InputStream in = fichero.getInputStream()) {
            Files.copy(in, destino, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
    ...
}

Spring Boot limita el tamaño por defecto a 1 MB por fichero y 10 MB por petición; se cambia con:

spring.servlet.multipart.max-file-size=2MB
spring.servlet.multipart.max-request-size=5MB

⚠️ Importante: Un fichero subido es una entrada hostil: nunca uses su nombre original para guardarlo (podría contener ../../ y escribir fuera de la carpeta), limita el tamaño y los tipos, y no lo guardes dentro de static/ ni en una carpeta desde la que el servidor pueda ejecutarlo.


11. Internacionalización

Todos los textos de las plantillas salen de src/main/resources/messages.properties con #{clave}. Es el idioma por defecto (español), y para cada idioma adicional se crea un fichero con su sufijo: messages_en.properties, messages_fr.properties…

# ==========================================================
#  Textos de la aplicación (español, idioma por defecto)
# ==========================================================
app.nombre=EMPRESA
menu.empleados=Empleados
menu.departamentos=Departamentos
inicio.bienvenida=Bienvenido a {0}
paginacion.resumen=Página {0} de {1} ({2} registros)
empleados.titulo=Empleados
empleados.nuevo=Nuevo empleado
empleado.nombre=Nombre
empleado.salario=Salario
...
# ---------- Mensajes de validación (Bean Validation) ----------
campo.max=No puede superar los {max} caracteres
empleado.nombre.obligatorio=El nombre es obligatorio
empleado.email.formato=El email no tiene un formato válido
empleado.salario.minimo=El salario mínimo es de {value} €
...
  • Los mensajes con parámetros ({0}, {1}) se usan así: #{inicio.bienvenida(${empresa})}.
  • Spring Boot lee messages*.properties en UTF-8 (spring.messages.encoding), a diferencia de application.properties: aquí sí puedes escribir tildes directamente.
  • Las claves que falten en messages_en.properties se toman de messages.properties.

⚠️ Importante: Añade spring.messages.fallback-to-system-locale=false (lo tenemos en application.properties). Si no, cuando el idioma pedido no tiene fichero propio (por ejemplo, es_ES, para el que no hay messages_es_ES.properties), Spring usa el idioma del sistema operativo antes que el fichero por defecto: en un servidor configurado en inglés, ¡tus usuarios verían la web en inglés!

Cambiar de idioma

Por defecto, Spring elige el idioma con la cabecera Accept-Language del navegador. En empresa-app lo guardamos en una cookie y permitimos cambiarlo con el parámetro ?lang= (enlaces «ES EN» de la barra):
/** El idioma se guarda en una cookie; por defecto, español. */
@Bean
public LocaleResolver localeResolver() {
    CookieLocaleResolver resolver = new CookieLocaleResolver("idioma");
    resolver.setDefaultLocale(Locale.of("es", "ES"));
    resolver.setCookieMaxAge(Duration.ofDays(30));
    return resolver;
}

/** ?lang=en cambia el idioma de la web. */
@Override
public void addInterceptors(InterceptorRegistry registry) {
    LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
    interceptor.setParamName("lang");
    registry.addInterceptor(interceptor);
}

Listado de empleados con la interfaz en inglés: Employees, New employee, Search, Name, Job, Department, Salary e importes con formato 2,600.00 € Figura 13. El listado con ?lang=en: textos de messages_en.properties y números con formato inglés. Captura real.

Los datos (oficios, nombres de departamento) siguen en español porque vienen de la base de datos; solo se traduce la interfaz.


12. Páginas de error personalizadas

Spring Boot incluye un controlador de errores (BasicErrorController) que, ante cualquier error en una petición de un navegador, busca una plantilla en este orden:

  1. templates/error/<código>.html (por ejemplo error/404.html).
  2. templates/error/<serie>xx.html (por ejemplo error/5xx.html).
  3. templates/error.html (genérica).
  4. Si no hay ninguna, la página blanca Whitelabel Error Page.

En la plantilla tienes estos atributos: status, error, message, path y timestamp (el mensaje de la excepción solo si server.error.include-message=always; por defecto está oculto por seguridad).

templates/error/404.html:

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      th:replace="~{fragmentos/layout :: layout(~{::title}, '', ~{::section})}">
<head>
    <title th:text="#{error.404.titulo}">Página no encontrada</title>
</head>
<body>
<section class="text-center py-5">
    <div class="display-1 text-primary fw-bold">404</div>
    <h1 class="h3" th:text="#{error.404.titulo}">Página no encontrada</h1>
    <!-- Spring Boot pone en el modelo: status, error, message, path, timestamp -->
    <p class="text-secondary" th:text="${message} ?: #{error.404.texto}">El recurso que buscas no existe.</p>
    <p class="small text-secondary"><code th:text="${path}">/empleados/999</code></p>
    <a class="btn btn-primary" th:href="@{/}"><i class="bi bi-house"></i> <span th:text="#{error.volver}">Volver al inicio</span></a>
</section>
</body>
</html>

Página 404 personalizada de empresa-app con el menú, el texto Página no encontrada, la ruta /empleados/999 y un botón Volver al inicio Figura 14. Página de error 404 personalizada para /empleados/999. Captura real.

error/500.html es análoga, con un mensaje genérico («Se ha producido un error inesperado»), y error.html cubre el resto de códigos. Para la API REST no se usan estas páginas: si la petición pide JSON (Accept: application/json), Boot responde en JSON, y nuestros controladores de web.api además tienen su @RestControllerAdvice (módulo 20).

⚠️ Importante: Nunca muestres la traza de una excepción al usuario: además de ser incomprensible, revela detalles internos (clases, consultas, versiones) que ayudan a un atacante. La traza va al log; al usuario, un mensaje amable.

💡 Consejo: Si quieres controlar los errores de los controladores MVC desde Java (por ejemplo, mostrar una vista concreta para una excepción), crea una clase @ControllerAdvice(basePackages = "es.curso.empresa.web.mvc") con métodos @ExceptionHandler que devuelvan el nombre de una vista. Es el equivalente web del @RestControllerAdvice del módulo 20.


13. El CRUD de departamentos

El CRUD de departamentos sigue el mismo esquema con una entidad más sencilla. El listado usa la proyección DepartamentoResumen del módulo 19, que trae el número de empleados de cada departamento en una sola consulta:

@Controller
@RequestMapping("/departamentos")
public class DepartamentoController {

    private final DepartamentoService servicio;

    public DepartamentoController(DepartamentoService servicio) {
        this.servicio = servicio;
    }

    @GetMapping
    public String listar(Model model) {
        model.addAttribute("departamentos", servicio.resumen());
        return "departamentos/lista";
    }

    @GetMapping("/{id}")
    public String detalle(@PathVariable Integer id, Model model) {
        model.addAttribute("departamento", servicio.buscarConEmpleados(id));   // @EntityGraph("empleados")
        return "departamentos/detalle";
    }

    @GetMapping("/nuevo")
    public String nuevo(Model model) {
        model.addAttribute("departamento", new DepartamentoForm());
        return "departamentos/formulario";
    }

    @GetMapping("/{id}/editar")
    public String editar(@PathVariable Integer id, Model model) {
        model.addAttribute("departamento", DepartamentoForm.desde(servicio.buscar(id)));
        return "departamentos/formulario";
    }

    @PostMapping("/guardar")
    public String guardar(@Valid @ModelAttribute("departamento") DepartamentoForm form,
                          BindingResult errores, RedirectAttributes flash) {
        if (errores.hasErrors()) {
            return "departamentos/formulario";
        }
        try {
            Departamento d = servicio.guardar(form);
            flash.addFlashAttribute("exito", "Departamento " + d.getNombre() + " guardado");
            return "redirect:/departamentos/" + d.getId();
        } catch (ReglaNegocioException ex) {
            errores.rejectValue("nombre", "duplicado", ex.getMessage());   // error asociado al campo
            return "departamentos/formulario";
        }
    }

    @PostMapping("/{id}/borrar")
    public String borrar(@PathVariable Integer id, RedirectAttributes flash) {
        try {
            servicio.borrar(id);
            flash.addFlashAttribute("exito", "Departamento borrado");
        } catch (ReglaNegocioException ex) {
            flash.addFlashAttribute("fallo", ex.getMessage());
        }
        return "redirect:/departamentos";
    }
}

Listado de los siete departamentos de EMPRESA con localidad, presupuesto y número de empleados; Investigación tiene 0 empleados y su botón de borrar está activo Figura 15. Listado de departamentos con el número de empleados. Captura real.

El botón de borrar está desactivado (th:disabled="${d.numEmpleados > 0}") en los departamentos con empleados, pero eso es solo una ayuda visual: la regla de verdad está en DepartamentoService.borrar, que lanza ReglaNegocioException si hay empleados. La interfaz nunca es la única barrera.

Detalle del departamento de Ventas: localidad Madrid, presupuesto 350.000,00 € y plantilla de seis empleados con oficio y salario Figura 16. Detalle de un departamento con su plantilla. Captura real.

En el servicio, guardar sirve para el alta y la edición, comprueba que no haya otro departamento con el mismo nombre (la tabla tiene una restricción UNIQUE) y lo guarda en mayúsculas, como los datos de EMPRESA:

@Transactional
public Departamento guardar(DepartamentoForm form) {
    Departamento d = form.isNuevo()
            ? new Departamento(form.getNombre(), form.getLocalidad(), form.getPresupuesto())
            : buscar(form.getId());
    boolean nombreCambiado = form.isNuevo() || !d.getNombre().equalsIgnoreCase(form.getNombre());
    if (nombreCambiado && departamentos.existsByNombreIgnoreCase(form.getNombre())) {
        throw new ReglaNegocioException("Ya existe un departamento llamado " + form.getNombre());
    }
    d.setNombre(form.getNombre().toUpperCase());
    d.setLocalidad(form.getLocalidad());
    d.setPresupuesto(form.getPresupuesto());
    return departamentos.save(d);   // en una edición no haría falta: la entidad está gestionada
}

Mapa de rutas de la web

Método y ruta Vista o redirección
GET / inicio
GET /empleados?q=&dept=&page=&sort= empleados/lista
GET /empleados/nuevo empleados/formulario (vacío)
GET /empleados/{id} empleados/detalle
GET /empleados/{id}/editar empleados/formulario (relleno)
POST /empleados/guardar Errores: empleados/formulario; bien: redirect:/empleados/{id}
POST /empleados/{id}/borrar redirect:/empleados o redirect:/empleados/{id}
POST /empleados/{id}/foto · GET /empleados/{id}/foto redirect:/empleados/{id} · imagen
GET /departamentos · /departamentos/{id} · /nuevo · /{id}/editar departamentos/lista · detalle · formulario
POST /departamentos/guardar · /{id}/borrar redirect:...

14. Ejercicios

Ejercicio 1: Totales en el listado de departamentos

Añade al final de la tabla de departamentos una fila con el presupuesto total (ignorando los departamentos sin presupuesto) y el total de empleados, calculados en la plantilla.

Ver solución
<tfoot class="table-light fw-semibold"
       th:with="conPresupuesto=${departamentos.?[presupuesto != null]}">
<tr>
    <td colspan="3">Total</td>
    <td class="text-end"
        th:text="${#numbers.formatDecimal(#aggregates.sum(conPresupuesto.![presupuesto]), 1, 'DEFAULT', 2, 'DEFAULT') + ' €'}">2.000.000,00 €</td>
    <td class="text-center" th:text="${#aggregates.sum(departamentos.![numEmpleados])}">24</td>
    <td></td>
</tr>
</tfoot>

HTML real generado para el pie de la tabla:

<tfoot class="table-light fw-semibold">
<tr>
    <td colspan="3">Total</td>
    <td class="text-end">1.800.000,00 €</td>
    <td class="text-center">23</td>
    <td></td>
</tr>
</tfoot>

(Si en la sección 8 diste de alta a Lucas Pérez Sanz en Informática, verás 24.) Se usan dos operadores de SpEL sobre colecciones: la selección .?[condición] (filtra: aquí quita Investigación, cuyo presupuesto es NULL, porque sumar un null daría error) y la proyección .![propiedad] (extrae una propiedad de cada elemento). El total de empleados es 23 y no 24 porque el becario no tiene departamento.

Ejercicio 2: Catálogo de productos

Crea una página /productos de solo lectura con los productos en tarjetas de Bootstrap (6 por página, ordenados por nombre), un desplegable para filtrar por categoría que envíe el formulario al cambiar, el precio formateado y la indicación «Agotado» cuando no haya stock. Los descatalogados deben verse atenuados.

Ver solución
@Controller
@RequestMapping("/productos")
public class ProductoController {

    private final ProductoService productos;
    private final CategoriaService categorias;

    public ProductoController(ProductoService productos, CategoriaService categorias) {
        this.productos = productos;
        this.categorias = categorias;
    }

    @GetMapping
    public String listar(@RequestParam(required = false) Integer categoria,
                         @PageableDefault(size = 6, sort = "nombre") Pageable pageable, Model model) {
        model.addAttribute("pagina", productos.listar(null, categoria, pageable));
        model.addAttribute("categorias", categorias.listar());
        model.addAttribute("categoria", categoria);
        return "productos/lista";
    }
}

templates/productos/lista.html:

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      th:replace="~{fragmentos/layout :: layout(~{::title}, 'productos', ~{::section})}">
<head>
    <title>Productos</title>
</head>
<body>
<section>
    <h1 class="h3 mb-3">Productos</h1>

    <!-- Filtro por categoría: al cambiar el select se envía el formulario -->
    <form th:action="@{/productos}" method="get" class="mb-3" style="max-width: 360px">
        <select name="categoria" class="form-select" onchange="this.form.submit()">
            <option value="">Todas las categorías</option>
            <option th:each="c : ${categorias}" th:value="${c.id}" th:text="${c.nombre}"
                    th:selected="${c.id == categoria}">Monitores</option>
        </select>
    </form>

    <div class="row row-cols-1 row-cols-md-3 g-3">
        <div class="col" th:each="p : ${pagina.content}">
            <div class="card h-100" th:classappend="${p.descatalogado} ? 'opacity-50'">
                <div class="card-body">
                    <h2 class="h6" th:text="${p.nombre}">Monitor 24 FHD</h2>
                    <p class="small text-secondary mb-2" th:text="${p.descripcion}">Descripción</p>
                    <span class="badge text-bg-light border" th:text="${p.categoria.nombre}">Monitores</span>
                </div>
                <div class="card-footer d-flex justify-content-between">
                    <strong th:text="${#numbers.formatDecimal(p.precio, 1, 'DEFAULT', 2, 'DEFAULT') + ' €'}">139,00 €</strong>
                    <span th:if="${p.stock > 0}" class="text-success" th:text="${p.stock + ' en stock'}">35 en stock</span>
                    <span th:unless="${p.stock > 0}" class="text-danger">Agotado</span>
                </div>
            </div>
        </div>
    </div>

    <nav class="mt-3" th:if="${pagina.totalPages > 1}">
        <ul class="pagination justify-content-center">
            <li class="page-item" th:each="i : ${#numbers.sequence(0, pagina.totalPages - 1)}"
                th:classappend="${i == pagina.number} ? 'active'">
                <a class="page-link" th:href="@{/productos(page=${i}, categoria=${categoria})}" th:text="${i + 1}">1</a>
            </li>
        </ul>
    </nav>
</section>
</body>
</html>

Catálogo de productos de la categoría Audio en tarjetas: Altavoz Inteligente atenuado y agotado, y Auriculares Bluetooth con 80 en stock Figura 17. Solución del ejercicio 2 filtrando por la categoría Audio. Captura real.

Reutilizamos ProductoService.listar del módulo 20, cuyos métodos de repositorio llevan @EntityGraph("categoria"); por eso ${p.categoria.nombre} no provoca consultas adicionales ni LazyInitializationException.

Ejercicio 3: Confirmación de borrado con una ventana modal

Sustituye el confirm() de JavaScript del detalle de empleado por una ventana modal de Bootstrap con el nombre del empleado y los botones «Cancelar» y «Sí, borrar».

Ver solución

El botón de la tarjeta ya no envía nada: abre la modal.

<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#confirmarBorrado">
    <i class="bi bi-trash"></i> <span th:text="#{boton.borrar}">Borrar</span>
</button>

Y el formulario POST pasa a la modal (dentro de la <section th:object="${empleado}">):

<div class="modal fade" id="confirmarBorrado" tabindex="-1" aria-labelledby="tituloBorrado" aria-hidden="true">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h2 class="modal-title fs-5" id="tituloBorrado">Borrar empleado</h2>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
            </div>
            <div class="modal-body">
                ¿Seguro que quieres borrar a <strong th:text="*{nombreCompleto}">Ana</strong>? Esta acción no se puede deshacer.
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
                <form th:action="@{/empleados/{id}/borrar(id=*{id})}" method="post">
                    <button class="btn btn-danger"><i class="bi bi-trash"></i> Sí, borrar</button>
                </form>
            </div>
        </div>
    </div>
</div>

Ventana modal de Bootstrap preguntando si se quiere borrar a Lucía Romero Serrano con los botones Cancelar y Sí, borrar Figura 18. Confirmación de borrado con una modal de Bootstrap. Captura real.

No hace falta escribir JavaScript: los atributos data-bs-toggle y data-bs-target los interpreta bootstrap.bundle.min.js, que el layout ya carga.

Ejercicio 4: El formulario en inglés

Completa messages_en.properties para que el formulario de empleados se vea en inglés (etiquetas y al menos los mensajes de nombre obligatorio, email y salario mínimo) y comprueba el resultado con ?lang=en.

Ver solución

Claves añadidas a messages_en.properties:

empleados.editar=Edit employee
empleado.apellidos=Last name
empleado.email=Email
empleado.telefono=Phone
empleado.fechaAlta=Hire date
empleado.comision=Commission
empleado.jefe=Manager
empleado.sinDepartamento=No department
empleado.sinJefe=No manager
empleado.nombre.obligatorio=Name is required
empleado.email.formato=Email is not valid
empleado.salario.minimo=Minimum salary is {value} €

Enviando el formulario vacío con el idioma inglés activo (extracto real del HTML devuelto):

<title>New employee</title>
<h1 class="h3 mb-3">New employee</h1>
<label for="nombre" class="form-label">Name</label>
<div class="invalid-feedback">Name is required</div>
<label for="apellidos" class="form-label">Last name</label>
<label for="email" class="form-label">Email</label>
<div class="invalid-feedback">Email is not valid</div>
<label for="telefono" class="form-label">Phone</label>
<label for="fechaAlta" class="form-label">Hire date</label>
<label for="oficio" class="form-label">Job</label>
<label for="salario" class="form-label">Salary</label>
<div class="invalid-feedback">Minimum salary is 900.00 €</div>
<label for="comision" class="form-label">Commission</label>
<label for="departamentoId" class="form-label">Department</label>
<label for="jefeId" class="form-label">Manager</label>

Los mensajes de validación también se traducen porque Spring Boot conecta Bean Validation con el MessageSource de la aplicación: {empleado.nombre.obligatorio} se resuelve en el idioma de la petición. Las claves que no hemos traducido (por ejemplo empleado.apellidos.obligatorio) siguen saliendo en español desde messages.properties.

Ejercicio 5: Un fragmento de tarjeta reutilizable

Crea un fragmento tarjeta(titulo, valor, icono, url) en fragmentos/tarjetas.html y úsalo en la página de inicio para mostrar el número de empleados, de departamentos y de productos, cada uno con su icono y enlace.

Ver solución
<!--/* Tarjeta reutilizable: título, valor, icono de Bootstrap Icons y enlace */-->
<div th:fragment="tarjeta(titulo, valor, icono, url)" class="card h-100 border-0 shadow-sm">
    <div class="card-body d-flex align-items-center gap-3">
        <i class="bi fs-1 text-primary" th:classappend="${icono}"></i>
        <div>
            <div class="text-secondary small" th:text="${titulo}">Título</div>
            <div class="fs-3 fw-semibold" th:text="${valor}">0</div>
        </div>
        <a class="stretched-link" th:href="@{${url}}" th:aria-label="${titulo}"></a>
    </div>
</div>

En inicio.html:

<div class="row g-3 mb-4">
    <div class="col-md-4" th:insert="~{fragmentos/tarjetas :: tarjeta(#{menu.empleados}, ${numEmpleados}, 'bi-people', '/empleados')}"></div>
    <div class="col-md-4" th:insert="~{fragmentos/tarjetas :: tarjeta(#{menu.departamentos}, ${numDepartamentos}, 'bi-diagram-3', '/departamentos')}"></div>
    <div class="col-md-4" th:insert="~{fragmentos/tarjetas :: tarjeta('Productos', ${numProductos}, 'bi-box-seam', '/productos')}"></div>
</div>

Y en InicioController, model.addAttribute("numProductos", productos.listar(null, null, Pageable.ofSize(1)).getTotalElements());.

Página de inicio con tres tarjetas: Empleados 24, Departamentos 7 y Productos 20, cada una con su icono Figura 19. Las tres tarjetas generadas con el mismo fragmento. Captura real.

Aquí es importante usar th:insert: conserva el <div class="col-md-4"> anfitrión y mete la tarjeta dentro. En nuestra primera versión usamos th:replace, que sustituye el div por el fragmento: la columna desaparecía y las tres tarjetas se apilaban ocupando todo el ancho. La clase stretched-link de Bootstrap hace que toda la tarjeta sea clicable.

Ejercicio 6: Validación entre campos

Solo los empleados con oficio VENDEDOR pueden tener comisión. Añade esta regla a EmpleadoForm con Bean Validation (sin tocar el controlador) y muestra el mensaje bajo el campo de comisión.

Ver solución
/** Solo los vendedores pueden tener comisión. */
@AssertTrue(message = "{empleado.comision.soloVendedores}")
public boolean isComisionValida() {
    return comision == null || comision.signum() == 0 || "VENDEDOR".equalsIgnoreCase(oficio);
}
empleado.comision.soloVendedores=Solo los empleados con oficio VENDEDOR pueden tener comisión

En la plantilla, bajo el grupo de la comisión:

<div class="text-danger small mt-1" th:if="${#fields.hasErrors('comisionValida')}"
     th:errors="*{comisionValida}">Solo los vendedores tienen comisión</div>

Enviando un PROGRAMADOR con 200 € de comisión, el HTML devuelto contiene (extracto real):

<div class="text-danger small mt-1">Solo los empleados con oficio VENDEDOR pueden tener comisión</div>

Bean Validation trata el método isComisionValida() como una propiedad comisionValida y comprueba que valga true. El error queda asociado a esa propiedad, que no tiene campo propio en el formulario: por eso lo mostramos explícitamente con th:errors="*{comisionValida}". Para reglas más complejas o reutilizables se crea una anotación propia con un ConstraintValidator.

Ejercicio 7 (propuesto): Conservar el orden al paginar

Los enlaces de la paginación de empleados pierden el orden elegido al pulsar en una cabecera de la tabla. Pasa el parámetro sort al fragmento de paginación para que se conserve, y marca con un icono ▲/▼ la columna por la que se está ordenando.

Ejercicio 8 (propuesto): CRUD web de categorías

Crea el CRUD completo de categorías (listado jerárquico con la categoría padre, alta, edición y borrado) reutilizando CategoriaService y el patrón PRG. El formulario debe tener un desplegable para elegir la categoría padre que no incluya a la propia categoría.

Ejercicio 9 (propuesto): Ficha de pedido imprimible

Crea /pedidos/{id} con los datos del cliente, las líneas en una tabla (cantidad, precio, descuento, importe) y el total, y una hoja de estilos @media print que oculte la barra de navegación y los botones al imprimir.

Ejercicio 10 (propuesto): Página 500 con identificador de incidencia

Haz que error/500.html muestre un identificador único de incidencia (por ejemplo un UUID) y que el mismo identificador se escriba en el log junto a la excepción, para que el usuario pueda comunicarlo al soporte. Pista: un @ControllerAdvice que capture Exception, genere el UUID, lo registre con SLF4J y lo añada al modelo.

Ejercicio 11 (propuesto): Bootstrap con WebJars

Sustituye la CDN de Bootstrap e iconos por WebJars en el layout y comprueba en las herramientas del navegador (pestaña Red) que los ficheros se sirven desde /webjars/....

Ejercicio 12 (propuesto): Mostrar la foto en el listado

Añade al listado de empleados una columna con una miniatura de la foto (o un icono si no tiene) y valida en FotoService que el fichero no supere los 500 KB, mostrando un mensaje flash si es mayor.


Resumen

Concepto Sintaxis / ejemplo
Controlador web @Controller, método que recibe Model y devuelve "empleados/lista"
Redirección return "redirect:/empleados/" + id;
Plantillas src/main/resources/templates/*.html, espacio de nombres th
Expresiones ${var}, *{prop}, #{mensaje}, @{/url/{id}(id=...)}, ~{fragmento}
Texto y bucles th:text, th:each="e, est : ${lista}", th:if/th:unless, th:classappend
Utilidades #numbers.formatDecimal, #temporals.format, #lists.size, #aggregates.sum
Fragmentos th:fragment="paginacion(pagina, url)", th:replace="~{fichero :: fragmento(...)}"
Layout th:replace="~{fragmentos/layout :: layout(~{::title}, 'activo', ~{::section})}"
Estáticos src/main/resources/static/ → @{/css/empresa.css}; Bootstrap 5.3.8 por CDN o WebJars
Formularios th:object="${empleado}", th:field="*{nombre}", th:errors, th:errorclass="is-invalid"
Validación @Valid @ModelAttribute("empleado") EmpleadoForm f, BindingResult errores, errores.reject(...)
PRG y flash RedirectAttributes.addFlashAttribute("exito", "...") + redirect:
Ficheros enctype="multipart/form-data", MultipartFile, spring.servlet.multipart.max-file-size
i18n messages.properties, messages_en.properties, #{clave(${param})}, LocaleChangeInterceptor
Errores templates/error/404.html, error/500.html, error.html; @ResponseStatus en excepciones