Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado priorizar la evolución del mismo producto. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Ya hay operaciones que cambian varias filas relacionadas. Una transacción agrupa cambios para que se confirmen juntos o se deshagan si la operación falla. Rollback es esa vuelta al estado anterior; hoy lo provocarás y comprobarás con datos.
El peligro del estado corrompido
En una aplicación empresarial, los casos de uso rara vez consisten en guardar una sola fila. Observa este escenario habitual:
Caso de uso: Archivar un proyecto obsoleto y reasignar sus 50 tareas pendientes al proyecto de mantenimiento general.
Imagina qué ocurre si este proceso se ejecuta sin una transacción atómica:
- La aplicación actualiza el proyecto obsoleto:
activo = false. (PostgreSQL lo guarda). - Empieza a reasignar las 50 tareas una a una.
- Al llegar a la tarea 23, se corta la conexión de red con el servidor, salta una excepción de validación o se agota la memoria.
El resultado es devastador: tu base de datos ha quedado corrompida. El proyecto figura como cerrado, 22 tareas están en mantenimiento, pero las otras 28 se han quedado en un limbo inaccesible. Reparar esa incoherencia a mano exigirá horas de auditoría forense con scripts SQL.
Las propiedades ACID explicadas para desarrolladores
Una transacción relacional es un escudo que garantiza cuatro propiedades matemáticas fundamentales:
- Atomicidad (todo o nada)
- Consistencia (reglas e invariantes)
- Aislamiento (sin interferencias)
- Durabilidad (persistido en disco)
| Propiedad | Qué significa en la práctica |
|---|---|
| A · Atomicidad (Atomicity) | Todo o nada. O todas las modificaciones del caso de uso se consolidan en PostgreSQL (COMMIT), o si una sola falla, la base de datos revierte todas las escrituras (ROLLBACK), dejándola exactamente como estaba al empezar. |
| C · Consistencia (Consistency) | La transacción lleva a la base de datos de un estado válido a otro estado válido, respetando todas las claves primarias, foráneas, tipos y restricciones de integridad. |
| I · Aislamiento (Isolation) | Dos usuarios ejecutando operaciones concurrentes no ven los cambios a medio cocinar del otro hasta que la transacción se haya confirmado definitivamente. |
| D · Durabilidad (Durability) | Una vez recibido el COMMIT, los datos quedan registrados en el disco (en el archivo WAL de PostgreSQL). Si se corta la corriente eléctrica un milisegundo después, los datos no se perderán. |
La trampa de la autoinvocación (Self-Invocation Problem)
Mira este código con atención. Contiene un error silencioso muy común:
@Service
public class ProyectoService {
public void procesoPublico() {
// ... tareas previas ...
this.operacionCritica(); // ¡PELIGRO!
}
@Transactional
public void operacionCritica() {
// Varias escrituras en base de datos
}
}
¿Qué ocurre al ejecutar procesoPublico()?
- Como
procesoPublico()no tiene@Transactional, el controlador llama directamente al servicio. - Al invocar
this.operacionCritica(), la llamada se resuelve mediante el punterothisinterno de Java, saltándose por completo el proxy de Spring. - El código de
operacionCritica()se ejecutará sin ninguna transacción. Cada instrucción SQL se confirmará por separado en modo autocommit.
Regla: para que @Transactional funcione, la llamada debe entrar desde un bean externo inyectado por Spring.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Elige la operación de varios cambios que describiste para tu proyecto: préstamo de varios ejemplares, pedido con líneas o equivalente. Abre el método del servicio que la coordinará.
- Anota las filas que crea o modifica y prepara datos de prueba para que un paso intermedio pueda fallar de manera controlada.
- Consulta y guarda el estado inicial en tu entorno local. Necesitarás compararlo después; no realices el experimento sobre datos de producción.
Paso 2 · Cómo funciona @Transactional por dentro
Cuando anotas una clase o método con @Transactional, Spring no modifica tu código Java. En su lugar, utiliza el patrón Proxy Dinámico (AOP):
Petición HTTP → Controlador → [ PROXY DE SPRING ] → Tu TareaService
│
1. connection.setAutoCommit(false)
2. Ejecuta tu método de servicio
3. ¿Terminó bien? → connection.commit()
4. ¿Lanzó error? → connection.rollback()
La trampa mortal de las excepciones comprobadas
Por defecto en Spring, @Transactional solo ejecuta ROLLBACK ante excepciones no comprobadas (subclases de RuntimeException y errores Error).
Si tu método lanza una excepción comprobada (como IOException, SQLException o cualquier clase que herede directamente de Exception), Spring asume que es una condición de negocio recuperable y ¡ejecuta COMMIT!
Para protegerte ante cualquier fallo inesperado, acostumbra a especificar:
@Transactional(rollbackFor = Exception.class).
Paso 3 · Implementar el caso multioperación «Clonar Proyecto»
Añade la operación al servicio del proyecto existente y conserva su constructor. El caso parte de un proyecto con tareas ya guardadas: carga ese origen dentro de la transacción, crea otras instancias sin id y copia solo los datos de negocio. Usa el método de relación para asociarlas al proyecto nuevo. La comprobación artificial de fallo es local y temporal; no debe quedar como una regla que se active por el título en producción.
@Transactional(rollbackFor = Exception.class)
public Proyecto clonarProyecto(Long idOriginal, String nuevoNombre) {
// 1. Obtener el proyecto original con sus tareas
Proyecto original = obtener(idOriginal);
// 2. Validar regla de negocio: el nombre del nuevo proyecto debe ser único
if (proyectoRepo.existsByNombreIgnoreCase(nuevoNombre)) {
throw new ReglaDeNegocioException("Ya existe un proyecto con el nombre: " + nuevoNombre);
}
// 3. Crear la nueva entidad padre
Proyecto clon = new Proyecto(nuevoNombre, "Clon de " + original.getNombre());
Proyecto clonGuardado = proyectoRepo.save(clon);
// 4. Duplicar cada una de las tareas del proyecto original
List<Tarea> tareasOriginales = tareaRepo.findByProyectoId(idOriginal);
for (Tarea tareaOriginal : tareasOriginales) {
// Simulador de fallo: si una tarea contiene "FALLO_TEST", abortamos
if (tareaOriginal.getTitulo().contains("FALLO_TEST")) {
throw new ReglaDeNegocioException("Error simulado: abortando clonación de emergencia");
}
Tarea nuevaTarea = new Tarea(
tareaOriginal.getTitulo() + " (Copia)",
tareaOriginal.getPrioridad()
);
nuevaTarea.setProyecto(clonGuardado);
nuevaTarea.setCompletada(false);
tareaRepo.save(nuevaTarea);
}
return clonGuardado;
}
- Por qué este método debe ser estrictamente atómico
- Si el proyecto original tiene 10 tareas y la séptima tarea provoca la excepción, la transacción aborta. PostgreSQL revierte el
INSERTdel nuevo proyecto y los seisINSERTde las tareas previas. No queda ninguna fila huérfana en la base de datos.
Paso 4 · Conectar el endpoint en ProyectoController
Añadimos el endpoint POST para disparar la clonación:
@PostMapping("/{id}/clonar")
public ResponseEntity<ProyectoResponse> clonar(
@PathVariable Long id,
@RequestParam String nuevoNombre) {
Proyecto clonado = servicio.clonarProyecto(id, nuevoNombre);
return ResponseEntity.status(HttpStatus.CREATED).body(ProyectoMapper.aRespuesta(clonado));
}
Paso 5 · El experimento del ROLLBACK real
Arranca la aplicación y ejecuta las dos pruebas siguientes:
-
Asegúrate de que el proyecto 1 tiene dos tareas normales (sin la palabra clave de error).
-
Ejecuta la petición:
POST http://localhost:8080/proyectos/1/clonar?nuevoNombre=Proyecto+Clonado+OK -
Respuesta:
201 Createdcon el nuevo proyecto. -
Consulta en PostgreSQL:
SELECT * FROM proyectos WHERE nombre = 'Proyecto Clonado OK'; SELECT * FROM tareas WHERE proyecto_id = (SELECT id FROM proyectos WHERE nombre = 'Proyecto Clonado OK');Verás el proyecto nuevo y sus dos tareas duplicadas con la coletilla
(Copia). -
Añade a propósito una tarea al proyecto 1 con el título problemático:
POST http://localhost:8080/tareas Content-Type: application/json { "titulo": "Tarea con FALLO_TEST para provocar rollback", "prioridad": "alta", "proyectoId": 1 } -
Ahora intenta clonar el proyecto 1 pidiendo crear
"Proyecto Destinado al Fracaso":POST http://localhost:8080/proyectos/1/clonar?nuevoNombre=Proyecto+Destinado+al+Fracaso -
La aplicación lanza la excepción y responde con un código
400 Bad Request(o el manejador de tu API):{ "error": "Regla de negocio violada", "detail": "Error simulado: abortando clonación de emergencia" } -
La prueba de fuego en PostgreSQL: abre tu cliente SQL y comprueba:
SELECT * FROM proyectos WHERE nombre = 'Proyecto Destinado al Fracaso';Resultado:
0 filas. PostgreSQL deshizo el proyecto nuevo y todas las tareas que se habían insertado antes de saltar la excepción. La base de datos está perfectamente limpia y coherente.
Paso 6 · Transferencia atómica de tareas entre proyectos
Prepara dos proyectos distintos y tareas solo en el origen. Comprueba primero existencia, diferencia entre ids y política del destino; después actualiza el lado propietario de cada tarea dentro de una única transacción. Si orphanRemoval=true, no uses para trasladar la tarea el método que la elimina de su propietario: podría programar su borrado. Define la transferencia expresamente, verifica claves foráneas al terminar y provoca un fallo intermedio para comprobar que vuelven al origen.
- Método:
transferirTareas(Long origenId, Long destinoId)anotado con@Transactional(rollbackFor = Exception.class). - Reglas de negocio a comprobar:
- El proyecto de origen debe existir.
- El proyecto de destino debe existir.
- El proyecto de destino debe estar activo (
destino.isActivo()). Si está inactivo, lanza unaReglaDeNegocioException("No se pueden transferir tareas a un proyecto inactivo").
- Si la validación pasa, reasigna todas las tareas de
origenadestino. - Expón el endpoint
POST /proyectos/{origenId}/transferir-a/{destinoId}. - Haz una prueba transfiriendo tareas hacia un proyecto inactivo: comprueba que responde con error y que en PostgreSQL ninguna tarea cambió de proyecto.
Paso 7 · Comprobar y registrar el resultado del proyecto
- Ejecuta el caso válido y verifica todos los cambios. Después provoca el fallo previsto y comprueba que no quedan cambios parciales.
- Retira el fallo artificial, repite y explica dónde está el límite de la transacción y qué excepción activa el rollback en tu implementación.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · Niveles de aislamiento y lecturas fantasma
Investiga cómo controla PostgreSQL la concurrencia entre transacciones simultáneas:
En @Transactional puedes configurar el parámetro isolation:
@Transactional(isolation = Isolation.READ_COMMITTED) // Por defecto en PostgreSQL
- Explica qué es una lectura sucia (Dirty Read), una lectura no repetible (Non-Repeatable Read) y una lectura fantasma (Phantom Read).
- Investiga por qué PostgreSQL no permite lecturas sucias bajo ningún concepto, incluso en su nivel más permisivo (
READ UNCOMMITTED). - ¿Qué ocurre si configuras
Isolation.SERIALIZABLEen un endpoint con miles de peticiones por segundo? Explica por qué el nivel serializable introduce una sobrecarga enorme de bloqueos y obliga a tu aplicación a capturar excepciones de reintento (could not serialize access due to concurrent update).
@Transactional y rollback verificado en PostgreSQL ante error.Ver respuestas
1 · Se cancelan y revierten por completo mediante un comando SQL ROLLBACK, dejando la base de datos en el estado exacto en que estaba antes de empezar.
2 · Por convención histórica de la arquitectura EJB/Spring: las excepciones comprobadas se consideraban situaciones de negocio esperadas que el programador debía gestionar, mientras que las RuntimeException se consideraban fallos técnicos imprevistos.
3 · Porque la llamada se ejecuta mediante la referencia interna this sin pasar por el proxy interceptor de Spring AOP que gestiona la conexión.
4 · En el registro de escritura anticipada (*Write-Ahead Logging* o archivo WAL).
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
No quedan cambios parciales al fallar y la operación correcta cumple todas las reglas del dominio.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.