CRUD con Thymeleaf y JPA¶
Hasta ahora las listas que se mostraban en las plantillas se construían en memoria, dentro del controlador. El resultado se ve bien, pero desaparece al reiniciar la aplicación. Este capítulo cierra ese hueco: los datos pasan a vivir en una base de datos y la vista los consulta a través de JPA.
Cómo encajan las piezas¶
Base de datos ↔ Entidad JPA ↔ Repository → @Controller → Model → Plantilla → HTML
persiste mapea consulta coordina lleva renderiza al
las filas tabla ↔ objeto y guarda la petición datos la vista navegador
| Componente | Responsabilidad |
|---|---|
| Entidad JPA | Representa una tabla; cada instancia es una fila |
| Repository | Ofrece las operaciones de consulta y escritura sin escribir SQL |
| @Controller | Recibe la petición, pide datos al repositorio y elige la vista |
| Model | Transporta los datos del controlador a la plantilla |
| Plantilla Thymeleaf | Renderiza el HTML final |
Una regla que no se negocia
Thymeleaf no accede a la base de datos. La plantilla solo consume lo que el controlador puso en el Model. Si en una vista aparece una consulta, la responsabilidad está mal ubicada.
Los conceptos de JPA (entidades, relaciones, ciclo de vida) se tratan a fondo en JPA y persistencia; aquí se usan en el contexto de una aplicación MVC con vistas.
1. Dependencias y configuración¶
Al proyecto creado en Creación del proyecto —que ya tiene Spring Web y Thymeleaf— hay que agregarle el acceso a datos:
<!-- JPA / Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Driver de MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validación de formularios -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Y la conexión en src/main/resources/application.properties:
spring.datasource.url=jdbc:h2:mem:demo
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true
Con H2 no hace falta instalar nada: la base vive en memoria mientras la aplicación corre y la consola queda disponible en http://localhost:8080/h2-console. Ideal para practicar; la configuración de H2 se detalla en Librerías extras.
Credenciales fuera del código
Reemplace los marcadores por sus valores locales, pero nunca suba contraseñas reales al repositorio. En un entorno real se inyectan por variables de entorno (SPRING_DATASOURCE_PASSWORD) o por un gestor de secretos.
Sobre ddl-auto: el valor update hace que Hibernate cree o ajuste las tablas al arrancar. Es cómodo en clase, pero en producción se usa validate junto con migraciones controladas (Flyway o Liquibase).
2. La entidad¶
@Entity
public class Producto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "El nombre es obligatorio")
private String nombre;
@NotNull(message = "El precio es obligatorio")
@DecimalMin(value = "0.0", inclusive = false, message = "El precio debe ser mayor que cero")
private BigDecimal precio;
public Producto() { }
public Producto(String nombre, BigDecimal precio) {
this.nombre = nombre;
this.precio = precio;
}
// getters y setters
}
| Anotación | Qué indica |
|---|---|
@Entity |
La clase es persistente: se corresponde con una tabla |
@Id |
El atributo es la clave primaria |
@GeneratedValue(strategy = IDENTITY) |
El identificador lo genera la base de datos (auto-incremento) |
El constructor vacío no es opcional
JPA necesita un constructor sin argumentos para instanciar la entidad al leer una fila, y Spring MVC lo necesita para enlazar el formulario. Si se declara un constructor con parámetros, hay que declarar también el vacío.
Los getters también son obligatorios: son los que Thymeleaf invoca cuando la plantilla escribe ${producto.nombre}.
3. El repositorio¶
Una interfaz vacía basta. Producto es la entidad y Long el tipo de su identificador; Spring Data genera la implementación en tiempo de ejecución.
| Método heredado | Para qué sirve |
|---|---|
findAll() |
Listar todos los registros |
findById(id) |
Buscar uno; devuelve un Optional |
save(producto) |
Crear (si no tiene id) o actualizar (si lo tiene) |
deleteById(id) |
Eliminar |
count(), existsById(id) |
Contar y verificar existencia |
Si se necesitan consultas propias, se declaran por convención de nombres:
List<Producto> findByNombreContainingIgnoreCase(String texto);
List<Producto> findByPrecioLessThan(BigDecimal maximo);
Más detalle en Repository.
4. El controlador¶
@Controller
@RequestMapping("/productos")
public class ProductoController {
private final ProductoRepository repository;
public ProductoController(ProductoRepository repository) {
this.repository = repository;
}
// LISTAR
@GetMapping
public String listar(Model model) {
model.addAttribute("productos", repository.findAll());
return "productos/lista";
}
// FORMULARIO DE CREACIÓN
@GetMapping("/nuevo")
public String nuevo(Model model) {
model.addAttribute("producto", new Producto());
return "productos/formulario";
}
// FORMULARIO DE EDICIÓN
@GetMapping("/{id}/editar")
public String editar(@PathVariable Long id, Model model) {
Producto producto = repository.findById(id).orElseThrow();
model.addAttribute("producto", producto);
return "productos/formulario";
}
// GUARDAR (crear o actualizar)
@PostMapping
public String guardar(@Valid @ModelAttribute("producto") Producto producto,
BindingResult result,
RedirectAttributes flash) {
if (result.hasErrors()) {
return "productos/formulario";
}
repository.save(producto);
flash.addFlashAttribute("mensaje", "Producto guardado correctamente");
return "redirect:/productos";
}
// ELIMINAR
@PostMapping("/{id}/eliminar")
public String eliminar(@PathVariable Long id, RedirectAttributes flash) {
repository.deleteById(id);
flash.addFlashAttribute("mensaje", "Producto eliminado");
return "redirect:/productos";
}
}
Detalles que vale la pena señalar:
- La inyección se hace por constructor, no con
@Autowiredsobre el atributo: así la dependencia es obligatoria y la clase se puede probar sin Spring. - Un único método guarda y actualiza, porque
save()decide según elidque traiga el objeto. - Eliminar usa
POST, noGET. Un enlaceGETque borra es peligroso: cualquier rastreador o precarga del navegador podría dispararlo. - Toda escritura termina en
redirect:, aplicando Post/Redirect/Get.
5. La vista de listado¶
src/main/resources/templates/productos/lista.html
<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Productos</title>
<link rel="stylesheet" th:href="@{/css/styles.css}">
</head>
<body>
<h1>Productos</h1>
<p th:if="${mensaje}" th:text="${mensaje}" class="alerta-exito">Mensaje</p>
<a th:href="@{/productos/nuevo}">Nuevo producto</a>
<p th:if="${#lists.isEmpty(productos)}">Todavía no hay productos registrados.</p>
<table th:unless="${#lists.isEmpty(productos)}">
<thead>
<tr><th>#</th><th>Nombre</th><th>Precio</th><th>Acciones</th></tr>
</thead>
<tbody>
<tr th:each="p, estado : ${productos}">
<td th:text="${estado.count}">1</td>
<td th:text="${p.nombre}">Nombre</td>
<td th:text="${#numbers.formatDecimal(p.precio, 1, 'POINT', 2, 'COMMA')}">0.00</td>
<td>
<a th:href="@{/productos/{id}/editar(id=${p.id})}">Editar</a>
<form th:action="@{/productos/{id}/eliminar(id=${p.id})}" method="post"
onsubmit="return confirm('¿Eliminar este producto?');">
<button type="submit">Eliminar</button>
</form>
</td>
</tr>
</tbody>
</table>
</body>
</html>
Todo lo que aparece aquí ya se explicó en Expresiones y atributos: th:each recorre la colección que el controlador publicó como productos, th:text muestra cada propiedad, @{...} construye los enlaces con el id de cada fila y #lists.isEmpty decide si se dibuja la tabla o el mensaje de lista vacía.
6. La vista de formulario¶
src/main/resources/templates/productos/formulario.html
<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Producto</title>
</head>
<body>
<h1 th:text="${producto.id} == null ? 'Nuevo producto' : 'Editar producto'">Producto</h1>
<form th:action="@{/productos}" th:object="${producto}" method="post">
<input type="hidden" th:field="*{id}">
<label>Nombre</label>
<input th:field="*{nombre}">
<small th:errors="*{nombre}">Error</small>
<label>Precio</label>
<input th:field="*{precio}" type="number" step="0.01">
<small th:errors="*{precio}">Error</small>
<button type="submit">Guardar</button>
<a th:href="@{/productos}">Cancelar</a>
</form>
</body>
</html>
La misma plantilla sirve para crear y para editar: lo único que cambia es el objeto que el controlador publicó. El campo oculto con el id es lo que le permite a save() distinguir entre INSERT y UPDATE, y el título cambia según ese mismo dato.
7. El flujo completo¶
Consultar
Crear o actualizar
GET /productos/nuevo → Producto vacío → productos/formulario.html
POST /productos → @Valid + binding → ¿errores?
sí → productos/formulario.html
no → repository.save() → redirect:/productos
Eliminar
La idea central se repite en las tres operaciones: JPA administra la persistencia, el controlador coordina y Thymeleaf presenta los datos. Ninguna de las tres capas invade el trabajo de las otras.
Datos de prueba¶
Para no arrancar con la tabla vacía, se puede sembrar información al iniciar la aplicación:
@Bean
CommandLineRunner datosIniciales(ProductoRepository repository) {
return args -> {
if (repository.count() == 0) {
repository.save(new Producto("Teclado", new BigDecimal("120.00")));
repository.save(new Producto("Mouse", new BigDecimal("75.00")));
repository.save(new Producto("Monitor", new BigDecimal("350.00")));
repository.save(new Producto("Auriculares", new BigDecimal("80.00")));
repository.save(new Producto("Webcam", new BigDecimal("60.00")));
}
};
}
La verificación count() == 0 evita que los datos se dupliquen en cada reinicio.
Errores comunes¶
| Síntoma | Causa habitual |
|---|---|
Table 'productos' doesn't exist |
La base de datos no existe todavía, o ddl-auto está en none |
No qualifying bean of type ProductoRepository |
La interfaz está fuera del paquete de la clase @SpringBootApplication |
NoSuchElementException al editar |
El id no existe; conviene manejarlo con orElseThrow(...) y una página de error |
| La lista se ve vacía aunque hay filas | La clave del Model no coincide con la expresión de la plantilla |
| Al editar se crea un registro nuevo | Falta el <input type="hidden" th:field="*{id}"> |
| Se pierde el mensaje tras el redirect | Se usó Model en vez de RedirectAttributes |
Reto final¶
Partiendo del listado del reto anterior, construya el CRUD completo de Producto conservando la arquitectura: @Controller, Model, JpaRepository y vistas Thymeleaf.
Entregables
- Convertir
Productoen una entidad JPA con validaciones. - Crear
ProductoRepositoryextendiendoJpaRepository. - Implementar en el controlador las cinco operaciones: listar, formulario de creación, formulario de edición, guardar y eliminar.
- Crear
productos/lista.htmlyproductos/formulario.html, reutilizando el mismo formulario para crear y editar. - Aplicar Post/Redirect/Get y mostrar mensajes de confirmación con
RedirectAttributes.
Criterio de éxito. Los productos creados desde el formulario siguen apareciendo después de reiniciar la aplicación, se pueden editar y eliminar, y un formulario inválido vuelve con los mensajes de error y los valores digitados intactos.
Para ir más lejos. Extraiga el acceso al repositorio a una clase @Service —como se explica en Servicios— para que el controlador deje de conocer la capa de persistencia, y mueva el encabezado y el pie de página a fragmentos reutilizables, según Templates.