TP2 - Transacciones y Rollback
Objetivos del TP
En este TP, usted va a:
- ✅ Crear un sistema de transferencia de dinero con transacciones
- ✅ Observar el comportamiento de los rollbacks
- ✅ Probar los diferentes niveles de propagación
- ✅ Comprender la trampa de la auto-invocación
- ✅ Utilizar transacciones de solo lectura
Duración estimada: 1h30
Contexto
Usted desarrolla una aplicación bancaria simplificada. Una transferencia de dinero debe:
- Debitar la cuenta de origen
- Acreditar la cuenta de destino
- Registrar la operación en una auditoría
Regla de oro: ¡Si una etapa falla, TODO debe ser anulado!
Parte 1: Configuración del Proyecto
1.1 Retomar el Proyecto TP7.1
Continúe con el proyecto datasource-demo del TP7.1, o cree un nuevo proyecto con las mismas dependencias.
1.2 Estructura
src/main/java/com/example/bank/
├── model/
│ ├── Account.java
│ └── TransactionLog.java
├── repository/
│ ├── AccountRepository.java
│ └── TransactionLogRepository.java
├── service/
│ ├── AccountService.java
│ ├── TransferService.java
│ └── AuditService.java
├── controller/
│ └── TransferController.java
└── exception/
└── InsufficientFundsException.java
Parte 2: Las Entidades
2.1 Account (Cuenta Bancaria)
// src/main/java/com/example/bank/model/Account.java
package com.example.bank.model;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String accountNumber;
@Column(nullable = false)
private String ownerName;
@Column(nullable = false, precision = 15, scale = 2)
private BigDecimal balance;
public Account(String accountNumber, String ownerName, BigDecimal balance) {
this.accountNumber = accountNumber;
this.ownerName = ownerName;
this.balance = balance;
}
public void debit(BigDecimal amount) {
if (this.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Solde insuffisant");
}
this.balance = this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
this.balance = this.balance.add(amount);
}
}
2.2 TransactionLog (Registro de Auditoría)
// src/main/java/com/example/bank/model/TransactionLog.java
package com.example.bank.model;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "transaction_logs")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TransactionLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String fromAccount;
@Column(nullable = false)
private String toAccount;
@Column(nullable = false, precision = 15, scale = 2)
private BigDecimal amount;
@Column(nullable = false)
private String status; // SUCCESS, FAILED
private String errorMessage;
@Column(nullable = false)
private LocalDateTime timestamp = LocalDateTime.now();
public TransactionLog(String from, String to, BigDecimal amount, String status) {
this.fromAccount = from;
this.toAccount = to;
this.amount = amount;
this.status = status;
this.timestamp = LocalDateTime.now();
}
}
2.3 Excepción Personalizada
// src/main/java/com/example/bank/exception/InsufficientFundsException.java
package com.example.bank.exception;
public class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(String message) {
super(message);
}
}
Parte 3: Los Repositories
// AccountRepository.java
package com.example.bank.repository;
import com.example.bank.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByAccountNumber(String accountNumber);
}
// TransactionLogRepository.java
package com.example.bank.repository;
import com.example.bank.model.TransactionLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TransactionLogRepository extends JpaRepository<TransactionLog, Long> {
List<TransactionLog> findByFromAccountOrToAccount(String from, String to);
}
Parte 4: Los Servicios
4.1 AccountService
// src/main/java/com/example/bank/service/AccountService.java
package com.example.bank.service;
import com.example.bank.exception.InsufficientFundsException;
import com.example.bank.model.Account;
import com.example.bank.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class AccountService {
private final AccountRepository accountRepository;
@Transactional(readOnly = true)
public List<Account> getAllAccounts() {
return accountRepository.findAll();
}
@Transactional(readOnly = true)
public Account getAccount(String accountNumber) {
return accountRepository.findByAccountNumber(accountNumber)
.orElseThrow(() -> new RuntimeException("Cuenta no encontrada: " + accountNumber));
}
@Transactional
public void debit(String accountNumber, BigDecimal amount) {
log.info("Débito de {} en la cuenta {}", amount, accountNumber);
Account account = getAccount(accountNumber);
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(
"Fondos insuficientes. Disponible: " + account.getBalance() + ", Solicitado: " + amount
);
}
account.debit(amount);
accountRepository.save(account);
log.info("Débito realizado. Nuevo saldo: {}", account.getBalance());
}
@Transactional
public void credit(String accountNumber, BigDecimal amount) {
log.info("Crédito de {} en la cuenta {}", amount, accountNumber);
Account account = getAccount(accountNumber);
account.credit(amount);
accountRepository.save(account);
log.info("Crédito realizado. Nuevo saldo: {}", account.getBalance());
}
@Transactional
public Account createAccount(String accountNumber, String ownerName, BigDecimal initialBalance) {
Account account = new Account(accountNumber, ownerName, initialBalance);
return accountRepository.save(account);
}
}
4.2 AuditService (Con REQUIRES_NEW)
// src/main/java/com/example/bank/service/AuditService.java
package com.example.bank.service;
import com.example.bank.model.TransactionLog;
import com.example.bank.repository.TransactionLogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class AuditService {
private final TransactionLogRepository logRepository;
/**
* Log en una NUEVA transacción.
* ¡Incluso si la transacción principal falla, el log se conserva!
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logTransaction(String from, String to, BigDecimal amount,
String status, String errorMessage) {
log.info("Registrando transacción: {} -> {} ({}€) - Estado: {}",
from, to, amount, status);
TransactionLog txLog = new TransactionLog(from, to, amount, status);
txLog.setErrorMessage(errorMessage);
logRepository.save(txLog);
log.info("Registro de transacción guardado con ID: {}", txLog.getId());
}
@Transactional(readOnly = true)
public List<TransactionLog> getLogsForAccount(String accountNumber) {
return logRepository.findByFromAccountOrToAccount(accountNumber, accountNumber);
}
@Transactional(readOnly = true)
public List<TransactionLog> getAllLogs() {
return logRepository.findAll();
}
}
4.3 TransferService (El Servicio Principal)
// src/main/java/com/example/bank/service/TransferService.java
package com.example.bank.service;
import com.example.bank.exception.InsufficientFundsException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
@Slf4j
public class TransferService {
private final AccountService accountService;
private final AuditService auditService;
/**
* Transferencia de dinero CON transacción.
* Si una etapa falla, todo es anulado (excepto el log de auditoría).
*/
@Transactional
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
log.info("=== Inicio de transferencia: {} -> {} ({}€) ===", fromAccount, toAccount, amount);
try {
// Paso 1: Debitar la cuenta de origen
accountService.debit(fromAccount, amount);
// Simular un error (descomentar para probar el rollback)
// if (amount.compareTo(new BigDecimal("100")) > 0) {
// throw new RuntimeException("¡Error simulado!");
// }
// Paso 2: Acreditar la cuenta de destino
accountService.credit(toAccount, amount);
// Paso 3: Registrar el éxito (REQUIRES_NEW = transacción separada)
auditService.logTransaction(fromAccount, toAccount, amount, "SUCCESS", null);
log.info("=== ¡Transferencia exitosa! ===");
} catch (InsufficientFundsException e) {
// Registrar el fallo (aunque la transacción principal esté en rollback)
auditService.logTransaction(fromAccount, toAccount, amount, "FAILED", e.getMessage());
throw e;
} catch (Exception e) {
// Registrar el fallo
auditService.logTransaction(fromAccount, toAccount, amount, "FAILED", e.getMessage());
throw new RuntimeException("Error durante la transferencia: " + e.getMessage(), e);
}
}
/**
* Versión SIN transacción (para demostración).
* ¡NO USAR EN PRODUCCIÓN!
*/
public void transferNoTransaction(String fromAccount, String toAccount, BigDecimal amount) {
log.warn("⚠️ Transferencia SIN transacción - ¡PELIGROSO!");
// Debitar
accountService.debit(fromAccount, amount);
// Simular un error después del débito
throw new RuntimeException("¡Error después del débito!");
// El crédito nunca será ejecutado
// accountService.credit(toAccount, amount);
}
}
Parte 5: Controller y Datos de Prueba
5.1 TransferController
// src/main/java/com/example/bank/controller/TransferController.java
package com.example.bank.controller;
import com.example.bank.model.Account;
import com.example.bank.model.TransactionLog;
import com.example.bank.service.AccountService;
import com.example.bank.service.AuditService;
import com.example.bank.service.TransferService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/bank")
@RequiredArgsConstructor
public class TransferController {
private final AccountService accountService;
private final TransferService transferService;
private final AuditService auditService;
// === CUENTAS ===
@GetMapping("/accounts")
public List<Account> getAllAccounts() {
return accountService.getAllAccounts();
}
@GetMapping("/accounts/{accountNumber}")
public Account getAccount(@PathVariable String accountNumber) {
return accountService.getAccount(accountNumber);
}
// === TRANSFERENCIAS ===
@PostMapping("/transfer")
public ResponseEntity<String> transfer(@RequestBody Map<String, Object> request) {
String from = (String) request.get("from");
String to = (String) request.get("to");
BigDecimal amount = new BigDecimal(request.get("amount").toString());
transferService.transfer(from, to, amount);
return ResponseEntity.ok("¡Transferencia exitosa!");
}
@PostMapping("/transfer-no-tx")
public ResponseEntity<String> transferNoTransaction(@RequestBody Map<String, Object> request) {
String from = (String) request.get("from");
String to = (String) request.get("to");
BigDecimal amount = new BigDecimal(request.get("amount").toString());
transferService.transferNoTransaction(from, to, amount);
return ResponseEntity.ok("¡Transferencia exitosa!");
}
// === AUDITORÍA ===
@GetMapping("/logs")
public List<TransactionLog> getAllLogs() {
return auditService.getAllLogs();
}
@GetMapping("/logs/{accountNumber}")
public List<TransactionLog> getAccountLogs(@PathVariable String accountNumber) {
return auditService.getLogsForAccount(accountNumber);
}
}
5.2 Datos Iniciales
-- src/main/resources/data.sql
INSERT INTO accounts (account_number, owner_name, balance) VALUES
('ACC001', 'Alice Martin', 1000.00),
('ACC002', 'Bob Dupont', 500.00),
('ACC003', 'Charlie Bernard', 2500.00);
5.3 Configuración
# application-dev.properties (agregar)
spring.jpa.defer-datasource-initialization=true
spring.sql.init.mode=always
Parte 6: Pruebas Prácticas
6.1 Prueba 1: Transferencia Exitosa
# Verificar los saldos iniciales
curl http://localhost:8080/api/bank/accounts
# Transferencia de 200€ de ACC001 a ACC002
curl -X POST http://localhost:8080/api/bank/transfer \
-H "Content-Type: application/json" \
-d '{"from":"ACC001", "to":"ACC002", "amount":200}'
# Verificar los nuevos saldos
curl http://localhost:8080/api/bank/accounts
# Verificar los logs
curl http://localhost:8080/api/bank/logs
Resultado esperado:
- ACC001: 1000 → 800€
- ACC002: 500 → 700€
- Log con estado "SUCCESS"
6.2 Prueba 2: Fondos Insuficientes (Rollback)
# Intentar una transferencia de 2000€ (ACC001 solo tiene 800€)
curl -X POST http://localhost:8080/api/bank/transfer \
-H "Content-Type: application/json" \
-d '{"from":"ACC001", "to":"ACC002", "amount":2000}'
# Verificar que los saldos NO han cambiado
curl http://localhost:8080/api/bank/accounts
# ¡Verificar que el log de fallo está presente!
curl http://localhost:8080/api/bank/logs
Resultado esperado:
- ACC001: sigue con 800€ (sin cambios)
- ACC002: sigue con 700€ (sin cambios)
- Log con estado "FAILED" y mensaje de error
6.3 Prueba 3: Sin Transacción (¡PELIGROSO!)
# Reiniciar la aplicación para reinicializar los datos
# Probar la transferencia sin transacción
curl -X POST http://localhost:8080/api/bank/transfer-no-tx \
-H "Content-Type: application/json" \
-d '{"from":"ACC001", "to":"ACC002", "amount":200}'
# Verificar los saldos
curl http://localhost:8080/api/bank/accounts
Resultado esperado (¡BUG!):
- ACC001: 1000 → 800€ (¡debitado!)
- ACC002: sigue con 500€ (¡nunca acreditado!)
- ¡200€ han desaparecido! 💀
Parte 7: Comprender REQUIRES_NEW
7.1 ¿Por qué se Conserva el Log de Auditoría?
Observe este esquema:
7.2 Ejercicio: Modificar el Comportamiento
- En
AuditService, cambieREQUIRES_NEWporREQUIRED - Vuelva a ejecutar la prueba 2 (fondos insuficientes)
- Observe: ¡el log ya no se crea en caso de fallo!
// Pruebe esto temporalmente:
@Transactional(propagation = Propagation.REQUIRED) // En lugar de REQUIRES_NEW
public void logTransaction(...) { }
Parte 8: La Trampa de la Auto-Invocación
8.1 El Problema
Agregue este método en TransferService:
// Método con la trampa de auto-invocación
public void transferWithLogging(String from, String to, BigDecimal amount) {
log.info("Llamada externa...");
// ⚠️ TRAMPA: Llamada directa = sin proxy = ¡sin transacción!
this.transfer(from, to, amount); // ¡@Transactional ignorado!
}
8.2 La Solución
@Service
@RequiredArgsConstructor
@Slf4j
public class TransferService {
private final AccountService accountService;
private final AuditService auditService;
// Inyección de sí mismo para pasar por el proxy
@Autowired
private TransferService self;
public void transferWithLogging(String from, String to, BigDecimal amount) {
log.info("Llamada externa...");
// ✅ Pasa por el proxy = ¡transacción activa!
self.transfer(from, to, amount);
}
@Transactional
public void transfer(String from, String to, BigDecimal amount) {
// ... código existente
}
}
Parte 9: Transacción ReadOnly
9.1 Verificar la Optimización
Agregue logging en AccountService:
@Transactional(readOnly = true)
public List<Account> getAllAccounts() {
log.info("Transaction readOnly = true");
List<Account> accounts = accountRepository.findAll();
// Esto NO será persistido porque readOnly = true
accounts.forEach(a -> a.setBalance(BigDecimal.ZERO));
return accounts;
}
Prueba:
curl http://localhost:8080/api/bank/accounts
# Los saldos mostrados son 0...
curl http://localhost:8080/api/bank/accounts
# ¡Pero los saldos reales no han cambiado!
Resumen
Lo que Ha Aprendido
| Concepto | Ejemplo |
|---|---|
| @Transactional | Garantiza atomicidad |
| Rollback | Anula todo en caso de error |
| REQUIRES_NEW | Transacción independiente |
| readOnly | Optimización para lecturas |
| Self-invocation | Trampa del proxy de Spring |
Reglas de Oro
1. Utilizar siempre @Transactional en operaciones de escritura
2. Usar REQUIRES_NEW para los logs de auditoría
3. Evitar la auto-invocación
4. Usar readOnly = true para las lecturas
5. Nunca tragar las excepciones
🎯 Próximo Paso
➡️ TP7.3: Migración con Flyway