Proyecto de practica para aprender y consolidar los conocimientos con SpringBoot para el desarrollo web, utilizando la conocida bbdd bd_scott.
- Creando la plantilla
- El Modelo de Datos
- Capa de Datos (Repository)
- Lógica de Negocio (Service)
- Capa Web (Controller)
- Frontend (Thymeleaf)
- SpringSecurity
Las dependencias instaladas son:
Spring Data JPA: Facilita la interaccion con la base de datos, mediante la implementacion deJpaRepositoryy lasQuery Methods. Tambien facilita las operaciones de Pagination y Sorting sin escribir sql. Todo medianteHibernateque es quien mueve los hilos detras deJPA.Spring Web: Esencial para crear aplicacioes web, APIRest o MVC tradicionales. Provee anotaciones esenciales como@RestController,@RequestMapping,@GetMapping,@PostMapping, etc.Spring Boot DevTools: Herramienta esencial para desarrollar. Tiene Automatic Restart cuando detecta cambios en elclasspath, refresca automaticamente la página cuando detecta cambiosHTMLoCSS.MySQL Driver: Conector especifico paraMySQLconJDBC Driver, implementa el estándarJava Database Connectivity. Traduce las llamadas deJavayJPAa sentenciasSQL.Thymeleaf: Se utiliza para elServer-Side Rendering(SSR). A diferencia de frameworks comoReactoAngularque renderizan en el navegador,Thymeleafgenera elHTMLen el servidor antes de enviarlo al cliente.Validation: Provee un mecanismo declarativo para asegurar la Integridad de los Datos que entran a la aplicación. Permite usar anotaciones en las clasesmodelcomo@NotNull,@Size,@Min,@Max, etc. Su integracion a losControllerses mediante el uso de la anotación@Valid. Esta dependencia evitar llenar la lógica con bloquesif-else.spring-boot-starter-security: Agrega los filtros de seguridad y la configuración base.thymeleaf-extras-springsecurity6: Permite usar etiquetas comosec:authorizeen el HTML para mostrar/ocultar botones según el rol.
Para crear un modelo, hay que tener en claro cuales son los campos de la tabla que vamos a modelar, despues de todo un modelo es una tabla en una bbdd. por ejemplo la tabla emp en la bbdd es la siguiente:
CREATETABLE `emp` (
`EMPNO`intNOT NULL,
`ENAME`varchar(45) CHARACTER SET utf8mb3 COLLATE utf8mb3_spanish2_ci DEFAULT NULL,
`JOB`varchar(9) CHARACTER SET utf8mb3 COLLATE utf8mb3_spanish2_ci DEFAULT NULL,
`MGR`int DEFAULT NULL,
`SAL` float DEFAULT NULL,
`COMM` float DEFAULT NULL,
`DEPTNO`int DEFAULT NULL,
`HIREDATE`date DEFAULT NULL,
PRIMARY KEY (`EMPNO`),
KEY `empleado_ibfk_1` (`DEPTNO`),
CONSTRAINT`emp_ibfk_1`FOREIGN KEY (`DEPTNO`)
REFERENCES`dept` (`DEPTNO`)
ON DELETE CASCADEONUPDATE CASCADE
)Al ver la tabla podemos observar detalles que luego nos seran utiles para modelar la tabla.
- El unico campo
NOT NULLesEMPNO. - La
PKes el campoEMPNOy no tieneAUTO INCREMENT - Tiene una auto referencia entre
EMPNOyMGR - Tiene una
FKa la tablaDEPTcon el campoDEPTNO ENAMEtiene un limite de 45 caracteres yJOBtiene un limite de 9 caracteres.
Con esa información ya se va armando el escenario para la creacion del modelo.
@Entity@Table(name = "emp")
publicclassEmp {
@Id@Column(name = "empno", nullable = false)
privateintempno;
@Column(name = "ename", length = 45)
privateStringename;
@Column(name = "job", length = 9)
privateStringjob;
@ManyToOne@JoinColumn(name = "mgr", referencedColumnName = "empno")
privateEmpmanager;
@Column(name = "sal")
privatefloatsal;
@Column(name = "comm")
privatefloatcomm;
@ManyToOne@JoinColumn(name = "deptno", referencedColumnName = "deptno")
privateDeptdept;
@Column(name = "HIREDATE")
privateLocalDatehiredate;
publicEmp() {
}
// Setters, Getter, Equals, HashCode y ToString
}@Entity: Marca la clase como una entidad. Esto indica al Persistence Context que esta clase debe ser mapeada a una tabla en la base de datos.- Parametros: si no tiene usa por defecto.
name: Permite definir un nombre logico para la entidad en las consultas JPQL. defaultEmp
- Parametros: si no tiene usa por defecto.
@Table(...): Especifica el nombre de la tabla en la base de datos.- Parametros:
name: Nombre de la tabla. Si se omiteJPAusara el nombre de la clase.schema: Define el esquema de base de datos (ej:public,sales).indexes: Permite definir indices secundarios (@Index) para optimizacion de consultas.
- Parametros:
@Id: Marca el campo como la clave primaria de la entidad. Normalmente se combina con:@GeneratedValuepara autogenerar valores, pero como esta tabla no usa autoincrement, elempnose ingresa manual o alternativas.- Parametros:
strategy = GenerationType.IDENTITY: Autoincremento de MySQL.strategy = GenerationType.SEQUENCE: Para Oracle/PostgreSQL.strategy = GenerationType.UUID: Estándar desde JPA 3.1.
- Parametros:
@Column(...): Mapea el atributo de la clase a una columna de la tabla.- Parametros:
name: Nombre de la columna en la base de datos (ej:empno,ename).nullable = false: Añade una restricciónNOT NULLa nivel de DDL y validación en runtime. Si no se agrega el default estrue.length: Define la longitud para tipoVARCHARoCHAR.unique:true/false. Crea una restricciónUNIQUEen la columna.updatable/insertable: Si esfalse, la columna se ignora en sentenciasUPDATEoINSERT.precision/scale: Para números decimales (ej:BigDecimal).commet: Añade un comentario al DLL de la columna.check: Permite definir una restricciónCHECKen la columna.
- Parametros:
@ManyToOne: Define una relación de cardinalidad "Muchos a Uno"- Muchos
Emptienen un manager (otroEmp) - Muchos
Emppertenecen a unDept - Parametros:
fetch: controla la estrategia de carga.FetchType.LAZY: Carga diferiad (Recomendado para performance).FetchType.EAGER: Carga inmediata (comportamiento por defecto para@ManyToOne).
cascade: Propagar operaciones.CascadeType.PERSIST: Si persisto una entidad padre, sus hijos tambien se persisten.CascadeType.MERGE: Si actualizo una entidad padre, sus hijos tambien se actualizan.CascadeType.REMOVE: Si borro una entidad padre, sus hijos tambien se borran.CascadeType.REFRESH: Si refresco una entidad padre, sus hijos tambien se refrescan.CascadeType.DETACH: Si detacho una entidad padre, sus hijos tambien se detachan.CascadeType.ALL: Propagar todas las operaciones.- TIP: No confundir
.REMOVEconorhpanRemoval=true. La primera solo borra al hijo si remuevo el padre explicitamente, mientras que la segunda Borra al hijo si simplemente se rompe la relación con el padre.
- Muchos
@JoinColumn(...): Especifica la comlumna que actúa comoFK.- Parametros:
name: Nombre de la columna.referencedColumnName: Nombre de la columna en la tabla relacionada (Emp.empnooDept.deptno).
- Parametros:
@OneToMany: La contramarte de@ManyToOne. Se usa en la ClaseDeptpara tener una lista de empleadosList<Emp>- Parametros:
mappedBy: Indica quien es el dueño de la relacion.
- Parametros:
@Lob:Large Object, esencial cuando se necesita almacenar grandes volúmenes de datos en un solo campo. Indica que la propiedad debe persistirse como unLarge Objecten la bbdd.- Variantes: Segun el tipo de datp para la columna.
CLOB: Character Large Object: Si es de tipoStringochar[].- Se usa para textos muy largos (ej: contenido de un articulo, un XML, un JSON, etc...).
BLOB: Binary Large Object: Si es de tipobyte[].- Se usa para archivos binarios (ej: imagenes, videos, sonidos, pdf's, etc...).
- Se combina con:
@Basic(fetch = FetchType.LAZY): Crucial. Por defecto los LOBs se cargan inmediatamente. Por rendimiento, casi siempre es mejor cargarlos bajo demanda (LAZY LOADING), para no traer 50MB de datos si solo necesitabas leer el ID o el nombre del usuario.@Column(length = 1000): En algunas bbdd ayuda a definir el tamaño máximo esperado.
- Variantes: Segun el tipo de datp para la columna.
@Enumerated: Sirve para mapear Java Enums. Define como se guarda un Enum en la BBDD.- Parametros:
EnumType.ORDINAL: Por defecto. Guarda el indice numerico del enum (0, 1, 2, 3, etc...). No recomendado, si cambias el orden de los enums en el código, se rompen los datos antiguos.EnumType.STRING: Recomendado. Guarda el nombre del enum como texto ("ACTIVO", "PENDIENDTE", "INACTIVO", etc...).
- Parametros:
@Transient: Indica a JPA que ignore este campo. No se creará una columna en la tabla y su valor no se guardará ni se recuperará de la base de datos. Se usa en campos calculados (ej. edad calculado a partir de fechaNacimiento), contraseñas en texto plano durante el registro (antes de ser hasheadas), o estados temporales de la UI.@Embeddedy@Embeddable: Estas anotaciones permiten aplicar principios de Domain-Driven Design (DDD) agrupando columnas relacionadas.@Embeddable: Se coloca en una clase POJO simple (no es una @Entity) para indicar que sus campos pueden ser integrados en otra tabla.@Embedded: Se usa en la Entidad principal para inyectar la clase @Embeddable.- Resultado en DB: No crea una tabla extra. Las columnas de la clase embebida se "aplanan" (flatten) dentro de la tabla de la entidad padre.
@ElementCollection: Se usa para una relación One-to-Many simple donde los hijos no son Entidades completas, sino tipos básicos (Strings, Integers) o clases@Embeddable. Una lista de teléfonos, etiquetas (tags) o roles simples.- Diferencia con
@OneToMany: El ciclo de vida depende totalmente del padre. Si borras al padre, desaparecen los elementos de la colección automáticamente sin necesidad de configurar cascadas complejas.
- Diferencia con
@Version: Optimistic Locking. Agrega control de concurrencia optimista. Cada vez que actualizas la entidad, JPA incrementa este número automáticamente. Si dos usuarios intentan guardar la misma entidad al mismo tiempo, el segundo fallará con unaOptimisticLockExceptionporque el número de versión ya no coincidirá.
Ya con el modelo creado podemos crear el repositorio.
Spring Data JPA utiliza una funcionalidad llamada Query Methods (o Derived Query Methods). Básicamente, interpreta el nombre del método en inglés y genera la SQL Query automáticamente en tiempo de ejecución.
publicinterfaceEmpRepositoryextendsJpaRepository<Emp, Integer>{
List<Emp> findByDept(Deptdept);
booleanexistsByEmpno(IntegerempNo);
Page<Emp> findByJobIgnoreCaseContaining(Stringjob, Pageablepageable);
Page<Emp> findByEnameIgnoreCaseContaining(Stringename, Pageablepageable);
Page<Emp> findBySalGreaterThanEqual(Floatsal, Pageablepageable);
Page<Emp> findByCommGreaterThanEqual(Floatcomm, Pageablepageable);
Page<Emp> findByDept_Deptno(Integerdeptno, Pageablepageable);
}List<Emp> findByDept(Dept dept);:- Busca empleados por Exact Match de la entidad Dept.
- SQL Generado: Algo como
SELECT * FROM emp WHERE dept_id = ?. - Detalle: Spring es lo suficientemente inteligente para tomar la Primary Key del objeto Dept que le pases y usarla en la cláusula WHERE.
boolean existsByEmpno(Integer empNo);:- Verifica si un registro existe sin traer toda la data.
- SQL Generado: Optimizado, usualmente
SELECT 1 FROM emp WHERE empno = ? LIMIT 1. - Ventaja: Es mucho más eficiente (High Performance) que hacer un
findBy...y verificar si la lista está vacía, ya que ahorra ancho de banda y memoria.
Page<Emp> findByJobIgnoreCaseContaining(String job, Pageable pageable);:- Búsqueda de texto flexible.
- Keywords:
IgnoreCase: Ignora mayúsculas/minúsculas (hace el match "insensitive").Containing: Agrega comodines (wildcards) automáticamente (%valor%).
- SQL Generado:
... WHERE UPPER(job) LIKE UPPER(%?%) .... - Pagination: El objeto Pageable agrega automáticamente
LIMITyOFFSETalSQLpara paginar los resultados.
Page<Emp> findByEnameIgnoreCaseContaining(...);:- Idéntico al anterior, pero aplicando el filtro sobre la columna ename. Útil para barras de búsqueda (Search Bars).
Page<Emp> findBySalGreaterThanEqual(Float sal, Pageable pageable);- Filtrado por rango numérico.
- Keyword:
GreaterThanEqualse traduce al operador matemático>=.
- SQL Generado:
... WHERE sal >= ? ... - Uso: "Dame todos los empleados que ganen X o más".
Page<Emp> findByCommGreaterThanEqual(Float comm, Pageable pageable);- Igual al anterior, aplicado a la comisión (comm).
Page<Emp> findByDept_Deptno(Integer deptno, Pageable pageable);:- Property Traversal (Navegación de propiedades).
- Aquí no pasas el objeto
Deptentero, sino solo suID(Integer). - SQL Generado:
... WHERE dept_id = ? ... - Sintaxis
_: El guion bajo (_) es explícito. Le dice a Spring: "Ve a la propiedad dept de Emp, entra en ella y busca su propiedad deptno". - Uso: Muy útil cuando recibes solo el ID desde el Frontend y no quieres instanciar un objeto
Deptcompleto para hacer la consulta.
Aunque los Derived Query Methods (los de arriba) cubren el 80% de los casos, en proyectos complejos necesitarás más control.
Cuando el nombre del método se vuelve ridículamente largo (ej. findByNameAndAgeLessThanAndActiveTrue...), o la consulta es muy compleja, usas @Query.
JPQL(Java Persistence Query Language): Consultas orientadas a objetos.// Consultas sobre la ENTIDAD (Emp), no sobre la tabla@Query("SELECT e FROM Emp e WHERE e.sal > :minSal AND e.job = 'MANAGER'") List<Emp> findRichManagers(@Param("minSal") FloatminSal);
Native Query:SQLpuro y duro (dependiente de la base de datos).@Query(value = "SELECT * FROM emp WHERE ename REGEXP '^[A-M]'", nativeQuery = true) List<Emp> findEmployeesStartWithAtoM();
Por defecto, los repositorios son solo para lectura. Si quieres hacer un UPDATE o DELETE personalizado con @Query, necesitas estas anotaciones extra.
@Modifying@Transactional// Requerido para operaciones de escritura@Query("UPDATE Emp e SET e.sal = e.sal * 1.10 WHERE e.dept.deptno = :deptno")
voidgiveRaiseToDepartment(@Param("deptno") Integerdeptno);Se usa para nombrar los parámetros en la query y evitar confusiones si cambias el orden de los argumentos en el método Java. Es una Best Practice para mantener el código legible.
A veces no quieres traer el objeto Emp completo (que puede ser pesado con muchas relaciones). Puedes definir una interfaz pequeña solo con los datos que necesitas. Spring implementará esto al vuelo.
// Interfaz ligerapublicinterfaceEmpNameAndSal {
StringgetEname();
FloatgetSal();
}
// En el repositorioList<EmpNameAndSal> findByJob(Stringjob);Esto hace el SQL SELECT ename, sal FROM... en lugar de SELECT *, optimizando el rendimiento.
Si necesitas filtros dinámicos (ej. un usuario filtra por nombre, otro por fecha, otro por ambos), crear métodos findBy para cada combinación es imposible. Para eso, tu repositorio extiende JpaSpecificationExecutor<Emp>. Esto te permite construir queries programáticamente (tipo "LEGO") usando la Criteria API.
Los @QueryHints son, metafóricamente, "susurros al oído" que le das a Hibernate para decirle cómo ejecutar una consulta de manera más eficiente.
A diferencia de @Query (que define qué datos traer), @QueryHints define cómo traerlos. No cambian el resultado de la búsqueda, pero sí el rendimiento y el consumo de recursos.
Son instrucciones específicas para el proveedor de persistencia. se usan para:
- Performance Tuning (Optimizar el rendimiento)
- Gestionar la memoria y evitar
OutOfMemoryError - Timeouts (Control de tiempos)
| Hint | Descripción | Cuando usar |
|---|---|---|
HINT_READ_ONLY | "Solo voy a leer, no voy a editar" | En reportes, listados, dashboards y cualquier GET masivo |
HINT_FETCH_SIZE | "Trae los datos por paquetes, no todos juntos" | Cuando esperas listas muy grandes (> 1000 registros) pero necesitas procesarlos todos |
HINT_CACHEABLE | "Guarda este resultado en caché (L2 Cache)" | Para datos que casi nunca cambian (ej. lista de países, categorías) |
jakarta.persistence.query.timeout | "Si tardas mucho, cancélalo" | Consultas pesadas que podrían bloquear la base de datos |
HINT_COMMENT | "Ponle una etiqueta a esta query en los logs de la BD" | Para debugging. Permite a los DBAs ver qué método Java causó esa query lenta. |
importjakarta.persistence.QueryHint;
importorg.springframework.data.jpa.repository.QueryHints;
importstaticorg.hibernate.jpa.HibernateHints.*;
publicinterfaceEmpRepositoryextendsJpaRepository<Emp, Integer> {
// 1. Optimización para listas grandes// Trae los empleados de 50 en 50 para no saturar la memoria RAM.@QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "50"))
List<Emp> findByDept(Deptdept);
// 2. Optimización para reportes y paginación (Solo lectura)// Desactiva el "Dirty Checking". Hace la consulta mucho más rápida.@QueryHints(@QueryHint(name = HINT_READ_ONLY, value = "true"))
Page<Emp> findByJobIgnoreCaseContaining(Stringjob, Pageablepageable);
// Aplicado a tus otras búsquedas paginadas...@QueryHints(@QueryHint(name = HINT_READ_ONLY, value = "true"))
Page<Emp> findByEnameIgnoreCaseContaining(Stringename, Pageablepageable);
// 3. Timeout (Seguridad)// Si la base de datos está lenta y tarda más de 3 segundos, corta la conexión// para no dejar al usuario esperando eternamente.@QueryHints(@QueryHint(name = "jakarta.persistence.query.timeout", value = "3000"))
Page<Emp> findBySalGreaterThanEqual(Floatsal, Pageablepageable);
}HINT_READONLY: Estos métodos que devuelvenPage<Emp>son claramente para mostrar datos en una tabla o lista en elFrontend. No vas a modificar esos objetos dentro de esa transacción.- El Problema: Por defecto,
Hibernateguarda una copia de cada objeto que traes en su memoria (First Level Cache) para hacerDirty Checking(verificar si algo cambió para hacer unupdate). Esto gastamemoriayCPUinútilmente si solo estás leyendo. - La Solución:
HINT_READONLYle dice aHibernate: "No vigiles estos objetos, solo entrégamelos".
- El Problema: Por defecto,
HINT_FETCH_SIZE: Para el métodofindByDept(Dept dept), si un departamento tiene 50,000 empleados,Hibernateintentará traerlos todos de golpe en una sola petición de red gigantesca o de forma muy ineficiente.- La Solución:
HINT_FETCH_SIZEle dice aldriver JDBC: "Tráeme los datos en paquetes de 50 en 50".
- La Solución:
A veces no quieres optimizar lectura, sino asegurar escritura.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Emp> findByEmpno(IntegerempNo);Esto hace un SELECT ... FOR UPDATE, bloqueando el registro en la BBDD para que nadie más lo toque hasta que termines.
Soluciona el famoso problema N+1 Selects. Si al traer Emp también quieres traer su Dept en la misma query (JOIN) en lugar de hacer queries separadas.
// Hace un LEFT JOIN con 'dept' automáticamente@EntityGraph(attributePaths = {"dept"})
List<Emp> findByJob(Stringjob);En la arquitectura de Spring Boot, el Service Layer es el corazón de la lógica de negocio (Business Logic). Es el intermediario que orquesta el flujo de datos entre el Controller (que recibe la petición HTTP) y el Repository (que habla con la base de datos).
publicinterfaceEmpService {
List<Emp> findAll();
Page<Emp> findAllPage(Pageablepageable);
voidsaveEmp(Empemp);
EmpsaveNewEmp(EmpnewEmp);
Optional<Emp> findById(Integerid);
List<Emp> findByDept(Deptdept);
voiddeleteById(Integerid);
Page<Emp> findByCriteria(Stringtype, Stringvalue, Pageablepageable);
}Define el "contrato" de operaciones disponibles.
Decoupling(Desacoplamiento): Permite que el controlador no dependa de la implementación concreta. Esto facilita el Unit Testing, ya que puedes crear "Mocks" de esta interfaz fácilmente.
@ServicepublicclassEmpServiceImplimplementsEmpService{
privatefinalEmpRepositoryempRepository;
publicEmpServiceImpl(EmpRepositoryempRepository){
this.empRepository = empRepository;
}
@OverridepublicvoiddeleteById(Integerid) {
empRepository.deleteById(id);
}
@OverridepublicList<Emp> findAll() {
returnempRepository.findAll();
}
@OverridepublicPage<Emp> findAllPage(Pageablepageable) {
returnempRepository.findAll(pageable);
}
@OverridepublicOptional<Emp> findById(Integerid) {
returnempRepository.findById(id);
}
@OverridepublicvoidsaveEmp(Empemp) {
empRepository.save(emp);
}
@OverridepublicEmpsaveNewEmp(Empemp) {
IntegerempnoFromUser = emp.getEmpno();
if(empnoFromUser == null || empnoFromUser <= 0) {
thrownewIllegalArgumentException("El 'EMPNO' no puede ser nullo o <= 0");
}
if (empRepository.existsByEmpno(empnoFromUser)) {
thrownewIllegalArgumentException("El 'EMPNO' ya existe");
}
returnempRepository.save(emp);
}
@OverridepublicList<Emp> findByDept(Deptdept) {
returnempRepository.findByDept(dept);
}
@OverridepublicPage<Emp> findByCriteria(Stringtype, Stringvalue, Pageablepageable) {
switch (type) {
case"ename":
returnempRepository.findByEnameIgnoreCaseContaining(value, pageable);
case"job":
returnempRepository.findByJobIgnoreCaseContaining(value, pageable);
case"sal":
try {
returnempRepository.findBySalGreaterThanEqual(Float.parseFloat(value), pageable);
} catch (NumberFormatExceptione) {
returnPage.empty();
}
case"comm":
try {
returnempRepository.findByCommGreaterThanEqual(Float.parseFloat(value), pageable);
} catch (NumberFormatExceptione) {
returnPage.empty();
}
case"deptno":
try {
returnempRepository.findByDept_Deptno(Integer.parseInt(value), pageable);
} catch (NumberFormatExceptione) {
returnPage.empty();
}
default:
break;
}
returnempRepository.findAll(pageable);
}
}@Service: Esta anotación registra la clase como un Spring Bean en el contenedor de inyección de dependencias (IoC Container). Por defecto, es un Singleton.Constructor
public EmpServiceImpl(EmpRepository empRepository){ ... }Estamos usando la
Constructor-based Dependency Injection, que es la Best Practice actual (superior a usar@Autowireden el campo). Hace que la dependencia sea obligatoria e inmutable, y facilita los tests sin necesidad de levantar todo el contexto deSpring.- Facilita el testing unitario (no necesitas levantar Spring Context) y asegura que el Bean no se instancie en un estado inválido (incompleto).
Validación de Negocio (
saveNewEmp): Aquí es donde resalta el servicio. No solo guardas el dato, sino que validas reglas de negocio (ej. "El ID no puede ser nulo", "El empleado no debe existir"). Esto protege la integridad de tu base de datos antes de siquiera intentar insertar.Lógica de Criterios (
findByCriteria): Centralize la lógica de filtrado dinámico.
Para llevar este servicio a un nivel profesional (Production Ready), te sugiero aplicar las siguientes mejoras clave:
Actualmente, mis métodos corren sin un contexto transaccional explícito (o dependen del que tenga el repositorio). Para optimizar debería controlar esto explícitamente en el servicio.
@Transactional(Clase o Métodos de escritura): GarantizaAtomicity. Si falla algo a mitad del método, se hace un Rollback de todo.@Transactional(readOnly = true): Para métodos de búsqueda (findAll,findById).
Beneficio: Mejora la Performance. Spring sabe que no necesita hacer "Dirty Checking" (revisar cambios en las entidades) y puede optimizar el uso de la conexión a la base de datos.
Puedes eliminar el constructor manual y usar @RequiredArgsConstructor. Esto genera un constructor con todos los campos marcados como final.
Es vital tener Observability. Necesitas saber qué pasa dentro del servicio cuando algo falla en producción.
En lugar de IllegalArgumentException (que es muy genérica), crea tus propias excepciones (ej. BusinessRuleException o ResourceNotFoundException) para manejarlas globalmente luego.
@Slf4j// 1. Genera un logger automáticamente (log.info, log.error)@Service@RequiredArgsConstructor// 2. Genera el constructor para 'empRepository' automáticamente@Transactional(readOnly = true) // 3. Por defecto, todas las operaciones son de solo lectura (optimización)publicclassEmpServiceImplimplementsEmpService {
privatefinalEmpRepositoryempRepository;
@Override@Transactional// 4. Sobreescribimos para permitir escritura (Write operations)publicvoiddeleteById(Integerid) {
log.info("Request to delete Emp with id: {}", id); // Logging estructuradoif (!empRepository.existsById(id)) {
thrownewRuntimeException("Empleado no encontrado para eliminar"); // Mejor manejo de errores
}
empRepository.deleteById(id);
}
@OverridepublicList<Emp> findAll() {
log.debug("Request to find all Emps");
returnempRepository.findAll();
}
// ... findAllPage, findById (heredan readOnly = true)@Override@Transactional// EscriturapublicvoidsaveEmp(Empemp) {
empRepository.save(emp);
}
@Override@Transactional// EscriturapublicEmpsaveNewEmp(Empemp) {
log.info("Request to save new Emp: {}", emp.getEname());
IntegerempnoFromUser = emp.getEmpno();
// Validaciones...if(empnoFromUser == null || empnoFromUser <= 0) {
thrownewIllegalArgumentException("El 'EMPNO' no puede ser nulo o <= 0");
}
if (empRepository.existsByEmpno(empnoFromUser)) {
// Sugerencia: Usar una excepción personalizadalog.warn("Intento de crear empleado duplicado: {}", empnoFromUser);
thrownewIllegalArgumentException("El 'EMPNO' ya existe");
}
returnempRepository.save(emp);
}
// ... resto de métodos
}Esta es la capa de presentación (Presentation Layer). Aquí es donde Spring MVC brilla, conectando la lógica de negocio (Service) con la interfaz de usuario (Thymeleaf).
Este código implementa un MVC Controller clásico (no confundir con REST API). Su función es preparar el Model (datos) y retornar el nombre de una View (plantilla HTML).
@Controller@RequestMapping("/emp")
publicclassEmpController {
// Inyeccion del EmpServiceprivatefinalEmpServiceempService;
privatefinalDeptServicedeptService;
publicEmpController(EmpServiceempService, DeptServicedeptService) {
this.empService = empService;
this.deptService = deptService;
}
@GetMapping("/list")
publicStringlistEmployees(
@RequestParam(value = "type", required = false) Stringtype,
@RequestParam(value = "value", required = false) Stringvalue,
@PageableDefault(page = 0, size = 10) Pageablepageable,
Modelmodel) {
Page<Emp> results;
if (value == null || value.isEmpty()) {
results = empService.findAllPage(pageable);
} else {
results = empService.findByCriteria(type, value, pageable);
}
model.addAttribute("empPage", results);
model.addAttribute("tipoSeleccionado", type);
model.addAttribute("valorBuscado", value);
return"emp/list-emp";
}
@GetMapping("/createEmp")
publicStringcreateEmployee(Modelmodel) {
model.addAttribute("emp", newEmp());
model.addAttribute("emps", empService.findAll());
model.addAttribute("depts", deptService.findDistinctBy());
model.addAttribute("editMode", "false");
return"emp/form-emp";
}
@PostMapping("/saveEmp")
publicStringsaveEmployee(@Valid@ModelAttribute("emp") Empemp,
BindingResultresult,
Modelmodel) {
if (result.hasErrors()) {
model.addAttribute("emps", empService.findAll());
model.addAttribute("depts", deptService.findDistinctBy());
return"emp/form-emp";
}
try {
empService.saveNewEmp(emp);
return"redirect:/emp/list";
} catch (IllegalArgumentExceptione) {
result.rejectValue("empno", "error.emp", e.getMessage());
model.addAttribute("error", e.getMessage());
return"emp/form-emp";
}
}
@GetMapping("/edit/{id}")
publicStringupdateEmp(@PathVariable("id") IntegeridEmp, Modelmodel){
model.addAttribute("emp", empService.findById(idEmp).get());
model.addAttribute("emps", empService.findAll());
model.addAttribute("depts", deptService.findDistinctBy());
model.addAttribute("editMode", "true");
return"emp/form-emp";
}
@PostMapping("/update")
publicStringupdateEmployee(@Valid@ModelAttribute("emp") Empemp, BindingResultresult, Modelmodel) {
if (result.hasErrors()) {
model.addAttribute("editMode", "true");
model.addAttribute("emps", empService.findAll());
model.addAttribute("depts", deptService.findDistinctBy());
return"emp/form-emp";
}
empService.saveEmp(emp);
return"redirect:/emp/list";
}
@GetMapping("/delete/{id}")
publicStringdeleteEmp(@PathVariable("id") IntegeridEmp, RedirectAttributesattributes) {
empService.deleteById(idEmp);
attributes.addFlashAttribute("msg", "Employee deleted");
return"redirect:/emp/list";
}
}@Controller: Marca la clase como unWeb Component. A diferencia de@RestController(que retornaJSON/XML), esta anotación indica que los métodos retornarán, por lo general, unStringque representa el nombre del archivoHTMLa renderizar.- Funcionamiento:
Springusa unViewResolverpara buscar ese nombre (ej."emp/list-emp") en la carpeta de templates.
- Funcionamiento:
@RequestMapping("/emp"): Define el prefijo base de la URL para todos los métodos de esta clase.- Efecto: Todas las rutas serán
localhost:port/emp/....
- Efecto: Todas las rutas serán
@GetMapping/@PostMapping: Mapean las peticionesHTTPGET(recuperar datos/vistas) yPOST(enviar datos de formularios) a métodos específicos.- Nota: Son atajos de
@RequestMapping(method = RequestMethod.GET).
- Nota: Son atajos de
@RequestParam- Uso:
public String listEmployees(@RequestParam(...) String type) - Concepto: Extrae parámetros de la
Query Stringde laURL(ej.?type=job&value=developer). required = false: Fundamental aquí. Si el usuario entra a/emp/listsin filtros, la aplicación no falla; las variables simplemente llegan comonull.
- Uso:
@PathVariable- Uso:
@GetMapping("/edit/{id}") - Concepto: Extrae valores dinámicos integrados directamente en la ruta (
URI Path), no después del signo?. RESTful Style: Es esencial para recursos identificables (ej. editar el empleado ID 5).
- Uso:
@ModelAttribute- Uso:
@ModelAttribute("emp") Emp emp - Concepto: Vincula los campos del formulario
HTMLcon el objetoJavaautomáticamente. Bidireccional:- De
JavaaHTML: Pone el objeto en el modelo para que la vista lo muestre. - De
HTMLaJava: Recibe los datos delPOST, instancia unEmpy llena sus campos (Setters).
- De
- Uso:
@Valid- Concepto: Activa la validación
JSR-380definida en la entidad (ej.@NotNull,@Size). Se ejecuta antes de entrar al cuerpo del método.
- Concepto: Activa la validación
BindingResult- Concepto: Es el contenedor de los errores de validación.
- Regla de Oro: Debe ir inmediatamente después del objeto anotado con
@Valid. Si ponemos otro argumento en medio (comoModel),Springlanzará una excepción. - Uso:
if (result.hasErrors())permite detener el proceso y devolver al usuario al formulario para que corrija los datos.
RedirectAttributes- Concepto: Se usa para el patrón
Flash Attributes. - Problema que resuelve: Cuando se hace un
redirect:/..., elModelse pierde (porque es una nueva peticiónHTTP).RedirectAttributesguarda datos temporalmente en la sesión y los borra apenas se leen. Ideal para mensajes de éxito ("Employee deleted").
- Concepto: Se usa para el patrón
@PageableDefault(page = 0, size = 10)- Concepto: Configura el comportamiento por defecto si el frontend no envía parámetros de paginación.
- Magia: Transforma parámetros de
URLcomo?page=1&size=5&sort=enamedirectamente en un objetoJava Pageable.
Para un entorno profesional, deberiamos conocer y considerar estas herramientas adicionales:
En el código uso @GetMapping para borrar (/delete/{id}) y @PostMapping para actualizar.
El Estándar:
RESTdicta usarDELETEpara borrar yPUT/PATCHpara actualizar.El Problema: Los formularios
HTML5nativos (<form>) solo soportanGETyPOST.La Solución:
Spring Bootpermite simular estos verbos usando un campo oculto en el formulario llamado_methody "engaña" alControllerpara que crea que recibió el verbo correcto:<inputtype="hidden" name="_method" value="delete"/>
Y en el
controllerdebiera usar@DeleteMapping("/{id}"). Esto es más semántico y correcto.Configuración Requerida: En las versiones modernas, este filtro suele estar deshabilitado por defecto para ahorrar recursos. Pero se puede activar en el
application.properties:# Habilita la simulación de verbos HTTP ocultosspring.mvc.hiddenmethod.filter.enabled=true
Correccion en el
Controller@DeleteMapping("/delete/{id}") // Recibe la petición transformadapublicStringdeleteEmp(@PathVariable("id") Integerid, RedirectAttributesattr) { empService.deleteById(id); attr.addFlashAttribute("msg", "Employee deleted successfully"); return"redirect:/emp/list"; }
Implementación con
Thymeleaf<formth:action="@{/emp/delete/{id}(id=${emp.id})}" th:method="delete"><buttontype="submit" class="btn btn-danger">Eliminar</button></form>
Actualmente estoy usando IllegalArgumentException con un try-catch dentro del método saveEmployee. Esto puede llegar a ser un problema ya que, si 20 métodos, tendre 20 try-catch.
Solución: Usar una clase anotada con @ControllerAdvice y métodos @ExceptionHandler. Esto centraliza el manejo de errores.
El patrón AOP (Aspect Oriented Programming) nos permite separar la lógica de manejo de errores de la lógica de negocio ("Separation of Concerns").
Crea un paquete exception o advice.
packagecom.bd_scott.app_bd_scott.advice;
importorg.springframework.web.bind.annotation.ControllerAdvice;
importorg.springframework.web.bind.annotation.ExceptionHandler;
importorg.springframework.ui.Model;
@ControllerAdvice// 1. Intercepta excepciones de TODOS los ControllerspublicclassGlobalExceptionHandler {
// Caso 1: Manejo específico de tu lógica de negocio@ExceptionHandler(IllegalArgumentException.class)
publicStringhandleBusinessError(IllegalArgumentExceptionex, Modelmodel) {
// Inyectamos el error en el modelo para mostrarlo en una vista amigablemodel.addAttribute("error", ex.getMessage());
model.addAttribute("status", "400 Bad Request");
return"error/generic-error"; // template: templates/error/generic-error.html
}
// Caso 2: Catch-all (Cualquier error no previsto)@ExceptionHandler(Exception.class)
publicStringhandleGeneralError(Exceptionex, Modelmodel) {
model.addAttribute("error", "Ocurrió un error inesperado. Contacte a soporte.");
model.addAttribute("details", ex.getMessage()); // Cuidado: no mostrar stacktrace en prodreturn"error/server-error";
}
}Con esto EmpController quedaria limpio. Ya no necesita try-catch en saveEmployee. Si el servicio lanza IllegalArgumentException, esta clase la atrapa y actua en consecuencia.
Permite configurar cómo se "bajan" los datos del Request al Objeto.
- Caso de uso: Convertir automáticamente strings vacíos "" a null, o formatear fechas personalizadas que vienen de inputs de texto plano.
@InitBinderpublicvoidinitBinder(WebDataBinderbinder) {
StringTrimmerEditorstringTrimmer = newStringTrimmerEditor(true);
binder.registerCustomEditor(String.class, stringTrimmer);
}Este es un punto de seguridad y limpieza de datos (Data Sanitization). Un problema clásico en formularios web es que un campo vacío llega como "" (String vacío) en lugar de null. Esto puede romper validaciones de base de datos o lógica condicional.
@Controller@RequestMapping("/emp")
publicclassEmpController {
// Se ejecuta ANTES de que los datos del form lleguen a los métodos @PostMapping@InitBinderpublicvoidinitBinder(WebDataBinderbinder) {
// StringTrimmerEditor es una clase utilitaria de Spring// true = convertir strings vacíos a nullStringTrimmerEditorstringTrimmer = newStringTrimmerEditor(true);
// Registramos el editor para todos los datos de tipo Stringbinder.registerCustomEditor(String.class, stringTrimmer);
}
// ... resto de métodos normales
}Resultado:
- Input usuario:
" "(espacios) -> Llega al Controller como:null - Input usuario:
""(vacío) -> Llega al Controller como:null - Input usuario:
" Scott "-> Llega al Controller como:"Scott"
Mantiene un objeto en la sesión HTTP entre múltiples peticiones.
Útil para formularios largos (Wizards) o para evitar hacer consultas repetitivas a la base de datos durante la edición de un objeto.
- Caso de uso: Un formulario
Wizardde varios pasos:- Paso 1: Datos personales
- Paso 2: Dirección
- Paso 3: Confirmación
- El objeto se mantiene vivo hasta que llamas a
status.setComplete()
@Controller@RequestMapping("/wizard")
@SessionAttributes("empleadoWizard") // 1. Guarda este atributo en la sesión HTTPpublicclassEmployeeWizardController {
@GetMapping("/step1")
publicStringshowStep1(Modelmodel) {
// Inicializamos el objeto, se guarda en sesión automáticamentemodel.addAttribute("empleadoWizard", newEmp());
return"wizard/step1"; // Pide Nombre y Apellido
}
@PostMapping("/step2")
publicStringprocessStep1(@ModelAttribute("empleadoWizard") Empemp) {
// El objeto 'emp' mantiene los datos del paso 1return"wizard/step2"; // Pide Departamento y Salario
}
@PostMapping("/finish")
publicStringfinishWizard(@ModelAttribute("empleadoWizard") Empemp,
SessionStatusstatus) { // 2. Inyectamos SessionStatus// Aquí 'emp' tiene los datos del paso 1 Y del paso 2 acumuladosempService.saveEmp(emp);
status.setComplete(); // 3. IMPORTANTE: Limpia el objeto de la sesiónreturn"redirect:/emp/list";
}
}Este es el concepto más crítico de seguridad web.
El Ataque:
Tienes una sesión activa en tu app bancaria o de gestión.
Recibes un correo spam con este HTML oculto:
<img src="http://tu-app.com/emp/delete/10" width="0" height="0">.Tu navegador intenta cargar la imagen. Al hacerlo, envía una petición
GETa esaURL.Como tienes cookies de sesión activas, el servidor acepta la petición y borra el registro 10.
Ni siquiera viste la imagen, pero la acción se ejecutó.
El método de borrar es un
@GetMapping:@GetMapping("/delete/{id}") // ¡PELIGRO!
Riesgo de Seguridad: Si un atacante te envía un link por correo
img src="http://tu-app/emp/delete/5", y estás logueado, el navegador intentará cargar esa imagen y borrará el empleado sin que te des cuenta.
La Solucion:
- Semántica: Usar
POST,PUToDELETE. Los navegadores no ejecutan estos verbos automáticamente en etiquetas como<img>o<a>. Solo mediante formularios o JavaScript explícito. CSRF Token:Spring Security(por defecto) espera un token único generado por el servidor en cada petición de cambio de estado.- Si un atacante intenta simular un
POST, fallará porque no tiene el token secreto generado para tu sesión específica.
<formaction="..." method="post"><inputtype="hidden" name="_csrf" value="a4f3-g5h6-..." />
...
</form>th:fragment="nombre": Define un bloque de código reutilizable.- Se le pueden pasar parametos como
th:fragment="header(title)"
- Se le pueden pasar parametos como
th:replace="~{ruta :: fragmento}": Reemplaza la etiqueta actual por el fragmento invocado. Es lo que uso para inyectar el navbar en cada página.- Ej:
th:replace="~{fragments/header :: header}" - Tambien puedes pasar argumentos:
th:replace="~{fragments/header :: header(title='Inicio')}"
- Ej:
th:href="@{/ruta}": Genera URLs relativas al contexto de la aplicación.- Si la app se despliega en
miservidor.com/miapp/,Thymeleafagrega/miappautomáticamente. Sin esto, tus enlaces se romperían en producción. - Permite parámetros de consulta:
@{/emp/list(page=1, size=10)}genera/emp/list?page=1&size=10.
- Si la app se despliega en
th:action="@{/ruta}": Igual quehref, pero para el atributo action de los formulariosHTML(<form>).
th:text="${variable}": Reemplaza el contenido de la etiqueta con el valor de la variable. Escapa caracteres especiales (XSS protection) por defecto.- Técnicas usadas en el código:
- Ternario:
${condicion ? 'Si' : 'No'}. - Safe Navigation (
?.):th:text="${emp.deptno?.dname}". Si deptno es null, no lanza error, simplemente no imprime nada. - Concatenación:
th:text="${m.ename + ' (' + m.empno + ')'}".
- Ternario:
- Técnicas usadas en el código:
th:object="${objeto}": Vincula un formulario a un objetoJava(Command Object).- Efecto: Establece el contexto para usar
*{...}.
- Efecto: Establece el contexto para usar
th:field="*{propiedad}": Vincula un input, select o textarea a una propiedad del objeto definido enth:object.- Magia: Asigna automáticamente el
id,name, yvalue. Si hay un error de validación, conserva el valor incorrecto para que el usuario no tenga que reescribirlo.
- Magia: Asigna automáticamente el
th:if="${condicion}": Renderiza la etiqueta solo si la condición es verdadera.- Ej:
th:if="${msg != null}"para mostrar alertas de éxito.
- Ej:
th:each="item : ${lista}": Itera sobre una lista (buclefor-each). Repite la etiquetaHTMLpor cada elemento.- Ej:
th:each="emp : ${empPage.content}"para llenar la tabla.
- Ej:
th:block: Es un contenedor "fantasma".Thymeleafprocesa lo que hay dentro, pero la etiqueta<th:block>desaparece en el HTML final.- Ej: Lo use para definir variables locales (
th:with) antes de incluir elheader
- Ej: Lo use para definir variables locales (
th:classappend="${condicion} ? 'clase-extra'": Añade una clase CSS si se cumple la condición, sin borrar las clases existentes.- Ej: Poner disabled en los botones de paginación.
th:value="${valor}": Establece el atributo value de un input estándar (cuando no usasth:field).th:selected="${condicion}": Marca una opción (<option>) como seleccionada en un desplegable si la condición estrue.th:readonly="${condicion}": Hace que un input sea de solo lectura dinámicamente.- Ej: Bloquear el campo
empnoen modo edición.
- Ej: Bloquear el campo
#fields.hasErrors('*'): Objeto utilitario para verificar si hay errores de validación (BindingResult).th:errors="*{campo}": Muestra el mensaje de error específico asociado a un campo.th:errorclass="is-invalid": Aplica una clase CSS (ej. borde rojo de Bootstrap) al input solo si ese campo tiene errores.#temporals.format(fecha, 'patron'): Formatea objetos de laAPIjava.time(comoLocalDate) directamente en la vista.
Esta es la solución para evitar repetir código. A diferencia de th:replace (que solo incrusta fragmentos), el Layout Dialect invierte el control: las páginas hijas "decoran" a la página padre.
Requisito Previo: Necesitas agregar la dependencia en tu
pom.xml(si no usasspring-boot-starter-thymeleafcompleto o versiones antiguas):<dependency> <groupId>nz.net.ultraq.thymeleaf</groupId> <artifactId>thymeleaf-layout-dialect</artifactId> </dependency>
Cómo funciona:
Base (
base.html): Define la estructura esqueleto (<html>,<head>,<body>, scripts comunes). Define "huecos" donde las hijas inyectarán contenido.Hija (
home.html): Solo contiene la data específica de esa vista.<!DOCTYPE html><htmlxmlns:th="[http://www.thymeleaf.org](http://www.thymeleaf.org)" xmlns:layout="[http://www.ultraq.net.nz/thymeleaf/layout](http://www.ultraq.net.nz/thymeleaf/layout)"><head><titlelayout:title-pattern="$CONTENT_TITLE - AppBD">AppBD</title></head><body><navth:replace="~{fragments/navbar :: navbar}"></nav><divlayout:fragment="content"></div><footerth:replace="~{fragments/footer :: footer}"></footer><scriptsrc="...bootstrap.js..."></script><th:blocklayout:fragment="page-scripts"></th:block></body></html>
<!DOCTYPE html><htmllayout:decorate="~{base}"><head><title>Inicio</title></head><body><divlayout:fragment="content"><h1>Bienvenido al Panel</h1></div><th:blocklayout:fragment="page-scripts"><script>console.log('Solo en home');</script></th:block></body></html>
Permite que tu aplicación hable varios idiomas sin duplicar HTML. Spring busca los archivos .properties basándose en el header Accept-Language del navegador o un parámetro de locale (?lang=es).
Estructura de Archivos:
src/main/resources/messages.properties(Inglés/Default)src/main/resources/messages_es.properties(Español)
Uso Avanzado (Parámetros): Puedes pasar variables dinámicas a los mensajes de texto.
En
messages.properties:welcome.user=Bienvenido, {0}! Hoy es {1}.En el HTML:
<pth:text="#{welcome.user(${session.user.name}, ${diaActual})}"></p>
Es la capacidad de Thymeleaf de escribir valores directamente en el cuerpo del texto o dentro de bloques de script, escapando los caracteres automáticamente para evitar errores de sintaxis o seguridad.
[[...]]vs[(...)]: Ejemplo insertando<b>Hola</b>[[${variable}]]: Escapa el HTML (seguro, texto plano). Muestra<b>Hola</b>.[(${variable})]: Renderiza el HTML (peligroso si viene del usuario). Muestra Hola.
JavaScript Inlining Inteligente: Thymeleaf es inteligente con los tipos de datos en JS. Si la variable es un String, le pone comillas. Si es número, no. Si es un objeto, intenta serializarlo a JSON.
<scriptth:inline="javascript">// Thymeleaf añade comillas automáticamente si es Stringconstusername=[[${user.name}]];// Si es null, escribe 'null' (sin comillas) para no romper el JSconstuserAge=[[${user.age}]];// Serialización de objetos (útil para pasar datos a React/Vue/Charts)constemployeesJson=[[${listaEmpleados}]];</script>
Cuando usas th:each, Thymeleaf crea automáticamente un objeto de estado. Si no le pones nombre, se llama nombreVariableStat.
- Propiedades útiles de
iterStat:.index: Índice base 0 (0, 1, 2...)..count: Contador base 1 (1, 2, 3...)..size: Tamaño total de la lista..even/.odd: Booleano, true si es par/impar..first/.last: Booleano, útil para estilos CSS (ej. bordes redondeados solo en el primero y último).<trth:each="emp, stat : ${emps}" th:classappend="${stat.even}? 'table-light' : ''"><tdth:text="${stat.count}"></td><tdth:text="${emp.ename}"><spanth:if="${stat.last}" class="badge bg-new">Nuevo</span></td></tr>
Funciona igual que en Java. Es preferible al th:if cuando tienes más de dos condiciones exclusivas, ya que Thymeleaf detiene la evaluación apenas encuentra una coincidencia (mejor rendimiento).
El comodín
*: Siempre debe ir al final y actúa como eldefault.<divth:switch="${emp.job}"><spanth:case="'PRESIDENT'" class="badge bg-danger">Jefe Supremo</span><spanth:case="'MANAGER'" class="badge bg-warning">Gerente</span><spanth:case="'ANALYST'" class="badge bg-info">Analista</span><spanth:case="*" class="badge bg-secondary">Staff</span></div>
A veces necesitas tocar atributos HTML que no son estándares o que Thymeleaf no mapea directamente (como data-* attributes para JavaScript).
th:attr: Permite asignar cualquier atributo.<trth:attr="data-id=${emp.id}, data-role=${emp.role}">...</tr>
th:stylevsth:styleappend:th:style: Reemplaza todo el estilo inline.th:styleappend: Agrega estilos sin borrar los que ya existan.<divclass="progress-bar" th:style="'width: ' + ${porcentajeAvance} + '%'" th:classappend="${porcentajeAvance > 90} ? 'bg-success' : 'bg-primary'"></div>
En esta fase hemos integrado Spring Security 6, el estándar de facto para asegurar aplicaciones en el ecosistema Spring. Hemos pasado de una aplicación abierta a una protegida con autenticación (¿quién eres?) y autorización (¿qué puedes hacer?).
- Authentication (Autenticación): Validación de credenciales (Usuario y Contraseña).
- Authorization (Autorización): Control de acceso basado en Roles.
ROLE_ADMIN: Acceso total (Crear, Editar, Borrar, Ver Usuarios).ROLE_MODERATOR: Acceso limitado (Solo lectura y actualizaciones).ROLE_USER: Acceso limitado (Solo lectura).
- Password Encoding: Las contraseñas nunca se guardan en texto plano. Usamos BCrypt.
En el pom.xml se agregaron:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
spring-boot-starter-security: Agrega los filtros de seguridad y la configuración base.thymeleaf-extras-springsecurity6: Permite usar etiquetas comosec:authorizeen el HTML para mostrar/ocultar botones según el rol.
Para poder tener tablas con campos personanilazos tenenos que crear los modelos JPA User y Role.
Estos modelos nos van a permitir guardar y manipular la informacion que necesitemos.
Tambien generamos una relacion many to many
En Spring Boot 3, ya no se hereda de clases base. Ahora se define un @Bean que retorna un SecurityFilterChain. Esto hace la configuración más modular y fácil de leer.
@Configuration@EnableWebSecuritypublicclassSecurityConfig {
@BeanpublicSecurityFilterChainfilterChain(HttpSecurityhttp) throwsException {
http
.authorizeHttpRequests(authorize -> authorize// Recursos estáticos públicos (CSS, JS, Imágenes)
.requestMatchers("/assets/**", "/css/**", "/js/**").permitAll()
// Páginas públicas (Login, Registro)
.requestMatchers("/login", "/register", "/saveUser").permitAll()
// Rutas restringidas solo a ADMIN
.requestMatchers("/users/**", "/emp/create**", "/emp/delete/**", "/emp/update/**").hasRole("ADMIN")
// El resto requiere autenticación (mínimo ROLE_USER)
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login") // Nuestra vista personalizada
.defaultSuccessUrl("/", true) // A dónde ir tras loguearse
.permitAll()
)
.logout(logout -> logout
.invalidateHttpSession(true)
.clearAuthentication(true)
.logoutRequestMatcher(newAntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/login?logout")
.permitAll()
);
returnhttp.build();
}
@BeanpublicBCryptPasswordEncoderpasswordEncoder() {
returnnewBCryptPasswordEncoder();
}
}@EnableWebSecurity: Habilita el soporte de seguridad web y la integración con Spring MVC.authorizeHttpRequests: Es el gestor de reglas de URL. El orden importa: de lo más específico a lo más general.formLogin: Configura la autenticación basada en formularios HTML. Si no se define.loginPage(), Spring genera una por defecto fea.passwordEncoder: Define el algoritmo de hash. Crucial: Spring Security espera que las contraseñas en la BBDD estén hasheadas con este mismo algoritmo.
Spring Security no sabe nada sobre nuestra tabla User o Role. Necesita un intermediario que traduzca nuestras entidades a algo que él entienda (UserDetails).
Para esto implementamos la interfaz UserDetailsService:
@ServicepublicclassUserDetailsServiceImplimplementsUserDetailsService {
privatefinalUserRepositoryuserRepository;
// Constructor Injection...@OverridepublicUserDetailsloadUserByUsername(Stringemail) throwsUsernameNotFoundException {
// 1. Buscamos nuestro usuario en la BBDDUseruser = userRepository.findByEmail(email);
if (user == null) {
thrownewUsernameNotFoundException("Usuario o password inválidos");
}
// 2. Mapeamos nuestros Roles a GrantedAuthority de Spring// Spring Security espera que los roles empiecen por "ROLE_" (ej: ROLE_ADMIN)Collection<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> newSimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
// 3. Retornamos el objeto User propio de Spring Securityreturnneworg.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(),
authorities);
}
}Para el registro de usuarios, no usamos la entidad User directamente en el controlador. Usamos un DTO (Data Transfer Object).
¿Por qué?
- Seguridad: Evita que un usuario malintencionado envíe campos extra (ej. inyectar un rol
ADMINen el formulario de registro). - Validación: Podemos validar cosas que no están en la BBDD, como que el campo "Confirmar Contraseña" coincida con "Contraseña".
@Getter@SetterpublicclassUserRegistrationDto {
@NotEmpty(message = "Nombre obligatorio")
@Size(max = 45, min = 3, message = "Mínimo 3 y máximo 45 caracteres")
privateStringname;
@NotEmpty(message = "Username obligatorio")
@Size(max = 45, min = 3, message = "Mínimo 3 y máximo 45 caracteres")
privateStringusername;
@Email(message = "Email inválido")
@NotEmpty(message = "Email obligatorio")
@Size(max = 100, min = 5, message = "Mínimo 5 y máximo 100 caracteres")
privateStringemail;
@NotEmpty(message = "Password obligatorio")
@Size(max = 100, min = 5, message = "Mínimo 5 y máximo 100 caracteres")
privateStringpassword;
@NotEmpty(message = "Confirmar el password es obligatorio")
@Size(max = 100, min = 5, message = "Mínimo 5 y máximo 100 caracteres")
privateStringconfirmPassword;
}Gracias a thymeleaf-extras-springsecurity6, podemos controlar qué ve el usuario.
<divsec:authorize="isAuthenticated()">
Bienvenido, <spansec:authentication="name">Usuario</span></div>En el Navbar o en las tablas, ocultamos los botones de borrar/editar para usuarios normales.
<liclass="nav-item" sec:authorize="hasRole('ROLE_ADMIN')"><aclass="nav-link" th:href="@{/users/list}">Gestión de Usuarios</a></li><lisec:authorize="isAuthenticated()"><ath:href="@{/logout}">Salir</a></li>- Usuario envía formulario POST a
/login. AuthenticationFilterintercepta la petición.- Llama al
AuthenticationManager. - Este delega en
UserDetailsServiceImpl.loadUserByUsername(). - Si el usuario existe, el
DaoAuthenticationProvidercomprueba si la contraseña (hasheada con BCrypt) coincide. - Si es correcto -> Se crea una
SessionHTTP y se guarda elSecurityContext. - Si falla -> Redirige a
/login?error.