Springboot básico
1. De Spring a Spring Boot
El problema que el proyecto SpringBoot resolvió
Antes de Spring Boot, configurar una aplicación Spring era un proceso largo y pesado:
Para una app Spring MVC simple (antes de Boot) necesitabas:
1. Crear proyecto Maven/Gradle manualmente
2. Buscar las dependencias correctas y sus versiones compatibles
(spring-core, spring-context, spring-web, spring-webmvc,
jackson-databind, hibernate-validator, logback, etc.)
3. Configurar web.xml con DispatcherServlet
4. Crear applicationContext.xml o AppConfig.java
5. Configurar el servidor de aplicaciones (Tomcat, JBoss, etc.)
6. Desplegar el .war en el servidor
7. Esperar que las versiones sean compatibles entre sí
¿Qué hace Spring Boot exactamente?
Spring Boot NO es un framework diferente. Es Spring con configuración automática inteligente basada en lo que detecta del classpath.
Spring Boot = Spring Framework
+ Auto-configuración (busca qué configurar)
+ Starter Dependencies (versiones compatibles pre-seleccionadas)
+ Servidor embebido (Tomcat incluido)
+ Spring Initializr (generador de proyectos)
Auto-configuración: el principio “Convención sobre Configuración”
// Spring Boot detecta las librerías en tu classpath y configura automáticamente:
// Si está spring-boot-starter-web en pom.xml:
// - DispatcherServlet configurado automáticamente
// - Tomcat embebido en puerto 8080
// - Jackson para serialización JSON
// - Manejo de errores básico
// Si está spring-boot-starter-data-jpa:
// - DataSource configurado (si hay application.properties con DB info)
// - EntityManagerFactory creado
// - TransactionManager configurado
// - Hibernate como proveedor JPA - ORM
// Si está spring-boot-starter-security:
// - Toda la aplicación protegida por defecto
// - Login form generado automáticamente
// - Usuario "user" con contraseña generada al inicio
// Se puede SOBREESCRIBIR cualquier auto-configuración:
@SpringBootApplication
@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class}) // excluir
public class Application { /* ... */ }
// O en application.properties:
// spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfigurationpom.xml: Antes vs Después de Boot
<!-- ANTES de Spring Boot — gestión manual de dependencias y versiones -->
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>5.3.21</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.3.21</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.3</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>7.0.4.Final</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>9.0.63</version>
</dependency>
<!-- ...10 dependencias más con versiones que debes compatibilizar tú... -->
</dependencies>
<!-- CON Spring Boot — un starter incluye todas las dependencias compatibles -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<dependencies>
<!-- Este starter incluye: spring-web, spring-webmvc, jackson, tomcat-embed, etc. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- Sin versión — la gestiona el parent (spring-boot-starter-parent) -->
</dependency>
</dependencies>@SpringBootApplication — Las 3 anotaciones en 1
// @SpringBootApplication es una "meta-anotación" que combina 3 anotaciones:
@SpringBootApplication
// equivale exactamente a:
@SpringBootConfiguration // es una @Configuration
@EnableAutoConfiguration // activa la auto-configuración de Boot
@ComponentScan // escanea el paquete actual y subpaquetes
public class MiAplicacion {
public static void main(String[] args) {
SpringApplication.run(MiAplicacion.class, args);
// Este método:
// 1. Crea el ApplicationContext
// 2. Registra todos los beans
// 3. Ejecuta la auto-configuración
// 4. Arranca el servidor Tomcat embebido
}
}application.properties — La configuración centralizada
# Servidor
server.port=8080
server.servlet.context-path=/api
# Base de datos
spring.datasource.url=jdbc:mysql://localhost:3306/mitienda
spring.datasource.username=root
spring.datasource.password=secret
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=update # create, create-drop, update, validate, none
spring.jpa.show-sql=true # mostrar SQL en consola
spring.jpa.properties.hibernate.format_sql=true
# Logs
logging.level.root=INFO
logging.level.com.ejemplo=DEBUG
logging.level.org.springframework.web=DEBUG
# Variables propias
app.nombre=Mi Tienda Online
app.version=1.0.0
app.correo.admin=admin@mitienda.com
2. Ejemplo de servicio REST
Configurar el proyecto
<!-- pom.xml mínimo para una API REST -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<dependencies>
<!-- Web + REST + Tomcat embebido -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Validación de datos -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok: elimina getters/setters/constructores boilerplate -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Estructura del proyecto
src/
├── main/
│ ├── java/com/ejemplo/tienda/
│ │ ├── TiendaApplication.java ← punto de entrada (Main)
│ │ ├── controller/
│ │ │ └── ProductoController.java ← endpoints REST
│ │ ├── service/
│ │ │ ├── ProductoService.java ← interface
│ │ │ └── ProductoServiceImpl.java ← implementación
│ │ ├── repository/
│ │ │ └── ProductoRepository.java ← acceso a datos
│ │ ├── model/
│ │ │ └── Producto.java ← entidad/modelo
│ │ └── dto/
│ │ ├── CrearProductoRequest.java ← request DTO
│ │ └── ProductoResponse.java ← response DTO
│ └── resources/
│ └── application.properties
El código completo
// 1. Punto de entrada
// TiendaApplication.java
@SpringBootApplication
public class TiendaApplication {
public static void main(String[] args) {
SpringApplication.run(TiendaApplication.class, args);
}
}// 2. Modelo
// model/Producto.java
public class Producto {
private Long id;
private String nombre;
private String descripcion;
private BigDecimal precio;
private int stock;
private boolean activo;
/*
Constructor, getters y setters
*/
}// 3. DTOs
// dto/CrearProductoRequest.java
// Se usan anotaciones del api Validation
public class CrearProductoRequest {
@NotBlank(message = "El nombre es obligatorio")
@Size(min = 2, max = 100, message = "El nombre debe tener entre 2 y 100 caracteres")
private String nombre;
private String descripcion;
@NotNull(message = "El precio es obligatorio")
@Positive(message = "El precio debe ser mayor a 0")
private BigDecimal precio;
@Min(value = 0, message = "El stock no puede ser negativo")
private int stock;
// getters y setters
}
// dto/ProductoResponse.java
public class ProductoResponse {
private Long id;
private String nombre;
private String descripcion;
private BigDecimal precio;
private int stock;
private boolean activo;
// Constructor estático: convierte Producto → ProductoResponse
// Existen alternativas como mapstruct que automatiza este proceso
public static ProductoResponse from(Producto p) {
ProductoResponse dto = new ProductoResponse();
dto.setId(p.getId());
dto.setNombre(p.getNombre());
dto.setDescripcion(p.getDescripcion());
dto.setPrecio(p.getPrecio());
dto.setStock(p.getStock());
dto.setActivo(p.isActivo());
return dto;
}
// getters y setters
}// 4. Repositorio
// repository/ProductoRepository.java
@Repository
public class ProductoRepository {
// Simula una base de datos en memoria
private final Map<Long, Producto> baseDeDatos = new ConcurrentHashMap<>();
private final AtomicLong contadorId = new AtomicLong(1);
public List<Producto> findAll() {
return new ArrayList<>(baseDeDatos.values());
}
public Optional<Producto> findById(Long id) {
return Optional.ofNullable(baseDeDatos.get(id));
}
public Producto save(Producto producto) {
if (producto.getId() == null) {
producto.setId(contadorId.getAndIncrement());
}
baseDeDatos.put(producto.getId(), producto);
return producto;
}
public void deleteById(Long id) {
baseDeDatos.remove(id);
}
public boolean existsById(Long id) {
return baseDeDatos.containsKey(id);
}
}// 5. Servicio
// service/ProductoService.java (interfaz)
public interface ProductoService {
List<Producto> listarTodos();
Optional<Producto> buscarPorId(Long id);
Producto crear(CrearProductoRequest request);
Producto actualizar(Long id, CrearProductoRequest request);
void eliminar(Long id);
}
// service/ProductoServiceImpl.java
@Service
public class ProductoServiceImpl implements ProductoService {
private final ProductoRepository repository;
// Inyección por constructor (Spring 4.3+: @Autowired es opcional)
public ProductoServiceImpl(ProductoRepository repository) {
this.repository = repository;
}
@Override
public List<Producto> listarTodos() {
return repository.findAll();
}
@Override
public Optional<Producto> buscarPorId(Long id) {
return repository.findById(id);
}
@Override
public Producto crear(CrearProductoRequest request) {
Producto producto = new Producto();
producto.setNombre(request.getNombre());
producto.setDescripcion(request.getDescripcion());
producto.setPrecio(request.getPrecio());
producto.setStock(request.getStock());
producto.setActivo(true);
return repository.save(producto);
}
@Override
public Producto actualizar(Long id, CrearProductoRequest request) {
Producto existente = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Producto no encontrado: " + id));
existente.setNombre(request.getNombre());
existente.setDescripcion(request.getDescripcion());
existente.setPrecio(request.getPrecio());
existente.setStock(request.getStock());
return repository.save(existente);
}
@Override
public void eliminar(Long id) {
if (!repository.existsById(id)) {
throw new EntityNotFoundException("Producto no encontrado: " + id);
}
repository.deleteById(id);
}
}// 6. Controlador REST
// controller/ProductoController.java
@RestController
@RequestMapping("/api/productos")
public class ProductoController {
private final ProductoService service;
public ProductoController(ProductoService service) {
this.service = service;
}
// GET /api/productos
// Listar todos los productos
@GetMapping
public ResponseEntity<List<ProductoResponse>> listar() {
List<ProductoResponse> productos = service.listarTodos()
.stream()
.map(ProductoResponse::from)
.collect(Collectors.toList());
return ResponseEntity.ok(productos);
}
// GET /api/productos/1
// Obtener un producto por ID
@GetMapping("/{id}")
public ResponseEntity<ProductoResponse> obtener(@PathVariable Long id) {
return service.buscarPorId(id)
.map(ProductoResponse::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/productos
// Crear un nuevo producto
@PostMapping
public ResponseEntity<ProductoResponse> crear(
@RequestBody @Valid CrearProductoRequest request
) {
Producto creado = service.crear(request);
ProductoResponse response = ProductoResponse.from(creado);
// 201 Created + Location header con la URL del recurso creado
URI location = URI.create("/api/productos/" + creado.getId());
return ResponseEntity.created(location).body(response);
}
// PUT /api/productos/1
// Actualizar un producto completo
@PutMapping("/{id}")
public ResponseEntity<ProductoResponse> actualizar(
@PathVariable Long id,
@RequestBody @Valid CrearProductoRequest request
) {
try {
Producto actualizado = service.actualizar(id, request);
return ResponseEntity.ok(ProductoResponse.from(actualizado));
} catch (EntityNotFoundException e) {
return ResponseEntity.notFound().build();
}
}
// DELETE /api/productos/1
// Eliminar un producto
@DeleteMapping("/{id}")
public ResponseEntity<Void> eliminar(@PathVariable Long id) {
try {
service.eliminar(id);
return ResponseEntity.noContent().build(); // 204 No Content
} catch (EntityNotFoundException e) {
return ResponseEntity.notFound().build();
}
}
}// 7. Manejo global de errores
// Buena práctica en aplicaciones rest modernas
// Los advices pertenecen al paradigma de programación orientado a aspectos, que es transversal a POO y se usa de gran manera en spring
// exception/GlobalExceptionHandler.java
@RestControllerAdvice // intercepta excepciones de todos los @RestController
public class GlobalExceptionHandler {
// Manejo de errores de validación (@Valid)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException ex
) {
Map<String, String> errores = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errores.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errores);
}
// Manejo de entidad no encontrada
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", ex.getMessage()));
}
// Cualquier otro error no manejado
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleGeneral(Exception ex) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Error interno del servidor"));
}
}Probar la API
# Crear un producto
curl -X POST http://localhost:8080/api/productos \
-H "Content-Type: application/json" \
-d '{"nombre":"Laptop Dell","descripcion":"Laptop para trabajo","precio":1299.99,"stock":10}'
# Respuesta:
# {
# "id": 1,
# "nombre": "Laptop Dell",
# "descripcion": "Laptop para trabajo",
# "precio": 1299.99,
# "stock": 10,
# "activo": true
# }
# Listar todos
curl http://localhost:8080/api/productos
# Obtener por ID
curl http://localhost:8080/api/productos/1
# Actualizar
curl -X PUT http://localhost:8080/api/productos/1 \
-H "Content-Type: application/json" \
-d '{"nombre":"Laptop Dell Pro","precio":1499.99,"stock":5}'
# Eliminar
curl -X DELETE http://localhost:8080/api/productos/13. Ejemplo MVC con Spring Boot
Configurar el proyecto para MVC
<!-- Agregar al pom.xml (además del starter-web) -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Thymeleaf — motor de plantillas HTML -->
<!-- En aplicaciones modernas se pueden usar frameworks como vaddin -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Validación -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies># application.properties
# Thymeleaf (configurado automáticamente, pero puedes personalizar)
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache=false # false en desarrollo (recarga plantillas al instante)
Estructura para MVC
src/main/
├── java/com/ejemplo/
│ ├── MvcApplication.java
│ ├── controller/
│ │ └── ProductoWebController.java ← @Controller
│ ├── service/
│ │ └── ProductoService.java
│ ├── model/
│ │ └── Producto.java
│ └── form/
│ └── ProductoForm.java ← formulario con validación
└── resources/
├── templates/
│ ├── layout/
│ │ └── base.html ← plantilla base compartida
│ └── productos/
│ ├── lista.html ← listado
│ ├── detalle.html ← detalle
│ └── formulario.html ← crear/editar
├── static/
│ ├── css/
│ │ └── estilos.css
│ └── js/
│ └── app.js
└── application.properties
El controlador MVC
// controller/ProductoWebController.java
@Controller // ← @Controller
@RequestMapping("/productos")
public class ProductoWebController {
private final ProductoService service;
public ProductoWebController(ProductoService service) {
this.service = service;
}
// Listar productos
// GET /productos
@GetMapping
public String lista(Model model) {
model.addAttribute("productos", service.listarTodos());
model.addAttribute("titulo", "Catálogo de Productos");
return "productos/lista"; // -> templates/productos/lista.html
}
// Ver detalle
// GET /productos/1
@GetMapping("/{id}")
public String detalle(@PathVariable Long id, Model model) {
Producto producto = service.buscarPorId(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Producto no encontrado"));
model.addAttribute("producto", producto);
return "productos/detalle"; // -> templates/productos/detalle.html
}
// Mostrar formulario de creación
// GET /productos/nuevo
@GetMapping("/nuevo")
public String mostrarFormularioCrear(Model model) {
model.addAttribute("producto", new ProductoForm()); // objeto vacío para el form
model.addAttribute("titulo", "Nuevo Producto");
model.addAttribute("accion", "/productos/nuevo");
return "productos/formulario";
}
// Procesar formulario de creación
// POST /productos/nuevo
@PostMapping("/nuevo")
public String procesarCrear(
@ModelAttribute("producto") @Valid ProductoForm form,
BindingResult errores, // DEBE ir inmediatamente después de @Valid
Model model,
RedirectAttributes redirectAttrs // para mensajes después del redirect
) {
if (errores.hasErrors()) {
// Si hay errores de validación, volver al formulario
model.addAttribute("titulo", "Nuevo Producto");
model.addAttribute("accion", "/productos/nuevo");
return "productos/formulario"; // mostrar el form con errores
}
service.crear(form);
// RedirectAttributes: el mensaje sobrevive al redirect
redirectAttrs.addFlashAttribute("mensajeExito", "Producto creado exitosamente");
return "redirect:/productos"; // redirect: evita doble submit con F5
}
// Mostrar formulario de edición
// GET /productos/1/editar
@GetMapping("/{id}/editar")
public String mostrarFormularioEditar(@PathVariable Long id, Model model) {
Producto producto = service.buscarPorId(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// Poblar el formulario con los datos existentes
ProductoForm form = new ProductoForm();
form.setNombre(producto.getNombre());
form.setDescripcion(producto.getDescripcion());
form.setPrecio(producto.getPrecio());
form.setStock(producto.getStock());
model.addAttribute("producto", form);
model.addAttribute("titulo", "Editar Producto");
model.addAttribute("accion", "/productos/" + id + "/editar");
return "productos/formulario"; // reutilizar el mismo template
}
// Procesar edición
// POST /productos/1/editar
@PostMapping("/{id}/editar")
public String procesarEdicion(
@PathVariable Long id,
@ModelAttribute("producto") @Valid ProductoForm form,
BindingResult errores,
Model model,
RedirectAttributes redirectAttrs
) {
if (errores.hasErrors()) {
model.addAttribute("titulo", "Editar Producto");
model.addAttribute("accion", "/productos/" + id + "/editar");
return "productos/formulario";
}
service.actualizar(id, form);
redirectAttrs.addFlashAttribute("mensajeExito", "Producto actualizado");
return "redirect:/productos";
}
// Eliminar
// POST /productos/1/eliminar (se usa POST porque HTML forms no soportan DELETE)
@PostMapping("/{id}/eliminar")
public String eliminar(@PathVariable Long id, RedirectAttributes redirectAttrs) {
service.eliminar(id);
redirectAttrs.addFlashAttribute("mensajeExito", "Producto eliminado");
return "redirect:/productos";
}
}Los templates Thymeleaf
<!-- templates/productos/lista.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="es">
<head>
<meta charset="UTF-8">
<title th:text="${titulo + ' — Mi Tienda'}">Catálogo</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 th:text="${titulo}">Catálogo</h1>
<a th:href="@{/productos/nuevo}" class="btn btn-primary">+ Nuevo Producto</a>
</div>
<!-- Mensaje de éxito (flash attribute del redirect) -->
<div th:if="${mensajeExito}" class="alert alert-success" role="alert">
<span th:text="${mensajeExito}">Operación exitosa</span>
</div>
<!-- Tabla de productos -->
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>Precio</th>
<th>Stock</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<!-- th:each itera la lista 'productos' del Model -->
<tr th:each="p : ${productos}">
<td th:text="${p.id}">1</td>
<td th:text="${p.nombre}">Nombre</td>
<td th:text="${'$' + #numbers.formatDecimal(p.precio, 1, 2)}">$0.00</td>
<td th:text="${p.stock}">0</td>
<td>
<!-- Condicional con th:if / th:unless -->
<span th:if="${p.activo}" class="badge bg-success">Activo</span>
<span th:unless="${p.activo}" class="badge bg-danger">Inactivo</span>
</td>
<td>
<!-- Links dinámicos con @{} -->
<a th:href="@{/productos/{id}(id=${p.id})}" class="btn btn-sm btn-info">Ver</a>
<a th:href="@{/productos/{id}/editar(id=${p.id})}" class="btn btn-sm btn-warning">Editar</a>
<!-- Formulario para eliminar (POST) -->
<form th:action="@{/productos/{id}/eliminar(id=${p.id})}"
method="post" style="display:inline"
onsubmit="return confirm('¿Confirmas la eliminación?')">
<button type="submit" class="btn btn-sm btn-danger">Eliminar</button>
</form>
</td>
</tr>
<!-- Fila vacía si no hay productos -->
<tr th:if="${#lists.isEmpty(productos)}">
<td colspan="6" class="text-center text-muted">No hay productos</td>
</tr>
</tbody>
</table>
</div>
</body>
</html><!-- templates/productos/formulario.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="es">
<head>
<meta charset="UTF-8">
<title th:text="${titulo + ' — Mi Tienda'}">Formulario</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
</head>
<body>
<div class="container mt-4">
<h1 th:text="${titulo}">Formulario</h1>
<!--
th:action = URL del form (viene del Model)
th:object = objeto del Model vinculado al formulario ("producto")
method="post" = siempre POST para formularios de creación/edición
-->
<form th:action="${accion}" th:object="${producto}" method="post" class="mt-3">
<div class="mb-3">
<label for="nombre" class="form-label">Nombre *</label>
<!--
th:field="*{nombre}" genera:
id="nombre", name="nombre", value="${producto.nombre}"
-->
<input type="text" class="form-control" id="nombre"
th:field="*{nombre}"
th:classappend="${#fields.hasErrors('nombre')} ? 'is-invalid'"/>
<!-- Mostrar error de validación -->
<div class="invalid-feedback" th:errors="*{nombre}">Error de nombre</div>
</div>
<div class="mb-3">
<label for="descripcion" class="form-label">Descripción</label>
<textarea class="form-control" id="descripcion"
th:field="*{descripcion}" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="precio" class="form-label">Precio *</label>
<input type="number" class="form-control" id="precio"
th:field="*{precio}" step="0.01" min="0"
th:classappend="${#fields.hasErrors('precio')} ? 'is-invalid'"/>
<div class="invalid-feedback" th:errors="*{precio}">Error de precio</div>
</div>
<div class="mb-3">
<label for="stock" class="form-label">Stock</label>
<input type="number" class="form-control" id="stock"
th:field="*{stock}" min="0"
th:classappend="${#fields.hasErrors('stock')} ? 'is-invalid'"/>
<div class="invalid-feedback" th:errors="*{stock}">Error de stock</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Guardar</button>
<a th:href="@{/productos}" class="btn btn-secondary">Cancelar</a>
</div>
</form>
</div>
</body>
</html>El ProductoForm con validaciones
// form/ProductoForm.java
// No es una entidad — es un objeto que mapea exactamente al formulario HTML
public class ProductoForm {
@NotBlank(message = "El nombre es obligatorio")
@Size(min = 2, max = 100, message = "Entre 2 y 100 caracteres")
private String nombre;
@Size(max = 500, message = "La descripción no puede exceder 500 caracteres")
private String descripcion;
@NotNull(message = "El precio es obligatorio")
@DecimalMin(value = "0.01", message = "El precio debe ser mayor a 0")
private BigDecimal precio;
@Min(value = 0, message = "El stock no puede ser negativo")
private int stock;
// getters y setters...
}4. Acceso a datos con JPA, ORM’s y JDBC
Spring - Bases de datos relacionales