Saltar a contenido

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:mysql://localhost:3306/<BASE_DATOS>
spring.datasource.username=<USUARIO>
spring.datasource.password=<CONTRASEÑA>
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
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

public interface ProductoRepository extends JpaRepository<Producto, Long> {
}

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 @Autowired sobre 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 el id que traiga el objeto.
  • Eliminar usa POST, no GET. Un enlace GET que 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

GET /productos → repository.findAll() → Model → productos/lista.html → HTML

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

POST /productos/{id}/eliminar → repository.deleteById() → redirect:/productos

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

  1. Convertir Producto en una entidad JPA con validaciones.
  2. Crear ProductoRepository extendiendo JpaRepository.
  3. Implementar en el controlador las cinco operaciones: listar, formulario de creación, formulario de edición, guardar y eliminar.
  4. Crear productos/lista.html y productos/formulario.html, reutilizando el mismo formulario para crear y editar.
  5. 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.