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
@Controllerque preparan unModely devuelven el nombre de una vista - Dominar la sintaxis de Thymeleaf:
th:text,th:each,th:if,th:hrefcon@{...},th:object/th:fieldy 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.
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
}
}
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
@Controllerdevuelve unStringque no corresponde a ninguna plantilla, verás un error 500Error resolving template [empleados/lsta]: revisa el nombre y la carpeta. Y si te equivocas y pones@RestController, el navegador mostrará literalmente el textoempleados/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:textescapa los caracteres especiales: si un empleado se llama<script>alert(1)</script>, se mostrará como texto y no se ejecutará. Usa siempreth:text;th:utextabre 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 (Localeen, 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})}">«</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})}">»</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:
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 comolayout:decorateylayout: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>
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):
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 enmessages.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 como2026-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
BindingResultdebe 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 elth:objectde la plantilla.
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:
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):
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:
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 ejemplotypeMismatch.java.math.BigDecimal=Debe ser un número), que también hemos añadido amessages.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?
Figura 10. Patrón Post/Redirect/Get con mensaje flash.
📘 Concepto: Con el patrón PRG (Post/Redirect/Get), después de procesar un
POSTque modifica datos, el servidor responde con una redirección 302 a una página que se carga conGET. Así, si el usuario recarga (F5) o pulsa «Atrás», el navegador repite elGET(inofensivo) y no elPOST(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:
Figura 11. Detalle del empleado recién creado con el mensaje flash de éxito. Captura real.
⚠️ Importante: No llames
errora un atributo flash. Spring Boot pone en el modelo de las páginas de error un atributoerrorcon 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 llamafallo.
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";
}
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 enlacesGETautomáticamente. Las operaciones que modifican datos van siempre porPOST(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.
- El formulario debe usar
method="post"yenctype="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>
- 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());
}
- 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 destatic/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*.propertiesen UTF-8 (spring.messages.encoding), a diferencia deapplication.properties: aquí sí puedes escribir tildes directamente. - Las claves que falten en
messages_en.propertiesse toman demessages.properties.
⚠️ Importante: Añade
spring.messages.fallback-to-system-locale=false(lo tenemos enapplication.properties). Si no, cuando el idioma pedido no tiene fichero propio (por ejemplo,es_ES, para el que no haymessages_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);
}
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:
templates/error/<código>.html(por ejemploerror/404.html).templates/error/<serie>xx.html(por ejemploerror/5xx.html).templates/error.html(genérica).- 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>
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@ExceptionHandlerque devuelvan el nombre de una vista. Es el equivalente web del@RestControllerAdvicedel 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";
}
}
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.
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>
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>
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());.
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 |