What are Transactions?
Think of a transaction like a bank transfer:
- Withdraw $100 from Account A
- Deposit $100 to Account B
- Either BOTH happen, or NEITHER happens
- You can't have money disappear or duplicate!
Jakarta Transactions (JTA) provides a standard API for managing transactions across multiple resources (databases, message queues, etc.) in enterprise applications.
ACID Properties
| Property | Meaning | Example |
|---|---|---|
| Atomicity | All or nothing | Both withdraw and deposit succeed, or both fail |
| Consistency | Valid state transitions | Total money in system stays the same |
| Isolation | Concurrent transactions don't interfere | Two transfers at same time don't corrupt data |
| Durability | Committed changes persist | After commit, data survives server crash |
Transaction Management Approaches
1. Container-Managed Transactions (CMT) - Recommended
import jakarta.ejb.*;
import jakarta.transaction.*;
@Stateless
public class BankService {
@Inject
private AccountRepository accountRepo;
// Container manages the transaction automatically!
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountRepo.findById(fromId);
Account to = accountRepo.findById(toId);
from.withdraw(amount); // If this fails...
to.deposit(amount); // ...this won't happen either!
accountRepo.save(from);
accountRepo.save(to);
// Transaction commits automatically when method returns
// Transaction rolls back if exception is thrown
}
}
2. Bean-Managed Transactions (BMT)
@Stateless
@TransactionManagement(TransactionManagementType.BEAN)
public class ManualTransactionService {
@Resource
private UserTransaction userTransaction;
@Inject
private AccountRepository accountRepo;
public void transfer(Long fromId, Long toId, BigDecimal amount) {
try {
// Start transaction manually
userTransaction.begin();
Account from = accountRepo.findById(fromId);
Account to = accountRepo.findById(toId);
from.withdraw(amount);
to.deposit(amount);
accountRepo.save(from);
accountRepo.save(to);
// Commit manually
userTransaction.commit();
} catch (Exception e) {
try {
// Rollback on error
userTransaction.rollback();
} catch (Exception ex) {
// Handle rollback error
}
throw new RuntimeException("Transfer failed", e);
}
}
}
Transaction Attributes (CMT)
@Stateless
public class TransactionDemo {
// REQUIRED (default): Join existing or create new
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void required() {
// If caller has transaction: join it
// If no transaction: create new one
}
// REQUIRES_NEW: Always create new transaction
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public void requiresNew() {
// Suspend caller's transaction (if any)
// Create new independent transaction
// Good for: audit logs that must persist even if main tx fails
}
// MANDATORY: Must have existing transaction
@TransactionAttribute(TransactionAttributeType.MANDATORY)
public void mandatory() {
// If caller has transaction: join it
// If no transaction: throw exception!
}
// SUPPORTS: Optional transaction
@TransactionAttribute(TransactionAttributeType.SUPPORTS)
public void supports() {
// If caller has transaction: join it
// If no transaction: run without transaction
}
// NOT_SUPPORTED: Never use transaction
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
public void notSupported() {
// Suspend caller's transaction (if any)
// Run without transaction
// Good for: read-only operations, external calls
}
// NEVER: Must NOT have transaction
@TransactionAttribute(TransactionAttributeType.NEVER)
public void never() {
// If caller has transaction: throw exception!
// If no transaction: run without transaction
}
}
| Attribute | No Tx Exists | Tx Exists | Use Case |
|---|---|---|---|
| REQUIRED | Create new | Join | Default, most methods |
| REQUIRES_NEW | Create new | Create new (suspend) | Audit logs, independent ops |
| MANDATORY | Error | Join | Force caller to have tx |
| SUPPORTS | No tx | Join | Optional tx support |
| NOT_SUPPORTED | No tx | No tx (suspend) | Read-only, external calls |
| NEVER | No tx | Error | Must not have tx |
CDI with @Transactional
import jakarta.transaction.Transactional;
import jakarta.transaction.Transactional.TxType;
@ApplicationScoped
public class OrderService {
@Inject
private OrderRepository orderRepo;
@Inject
private PaymentService paymentService;
@Inject
private InventoryService inventoryService;
// Equivalent to TransactionAttributeType.REQUIRED
@Transactional(TxType.REQUIRED)
public Order createOrder(OrderRequest request) {
// All these operations are in the same transaction
Order order = new Order(request);
orderRepo.save(order);
paymentService.processPayment(order);
inventoryService.reserveItems(order);
return order;
// Commit on success, rollback on exception
}
@Transactional(TxType.REQUIRES_NEW)
public void logOrderAttempt(OrderRequest request) {
// Logs in separate transaction
// Persists even if main order fails
}
// Specify which exceptions cause rollback
@Transactional(
rollbackOn = {PaymentException.class},
dontRollbackOn = {ValidationWarning.class}
)
public void processPayment(Order order) {
// PaymentException: rolls back
// ValidationWarning: commits anyway
}
}
Important!
@Transactional only works on CDI beans when called from outside the bean. Calling a transactional method from within the same class won't start a new transaction!
@ApplicationScoped
public class BrokenExample {
@Transactional
public void methodA() {
// Transaction started
methodB(); // NO new transaction! Same class call bypasses proxy
}
@Transactional(TxType.REQUIRES_NEW)
public void methodB() {
// Expected new transaction, but didn't get one!
}
}
// Solution: Use self-injection
@ApplicationScoped
public class FixedExample {
@Inject
private FixedExample self; // Self-inject the proxy
@Transactional
public void methodA() {
self.methodB(); // Now goes through proxy, new tx created!
}
@Transactional(TxType.REQUIRES_NEW)
public void methodB() {
// Correctly runs in new transaction
}
}
Rollback Handling
Automatic Rollback (Runtime Exceptions)
@Transactional
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
Account from = accountRepo.findById(fromId);
Account to = accountRepo.findById(toId);
from.withdraw(amount);
if (amount.compareTo(new BigDecimal("10000")) > 0) {
// RuntimeException = automatic rollback
throw new IllegalArgumentException("Amount too large");
}
to.deposit(amount);
// If we get here, transaction commits
}
Manual Rollback
@Stateless
public class OrderService {
@Resource
private SessionContext ctx; // EJB context
public void createOrder(OrderRequest request) {
try {
// ... order logic
if (!validateOrder(request)) {
// Mark transaction for rollback
ctx.setRollbackOnly();
return; // Transaction will rollback when method ends
}
} catch (Exception e) {
ctx.setRollbackOnly();
throw e;
}
}
}
Programmatic Rollback Check
@Inject
private TransactionManager tm;
public void someMethod() {
try {
// Check if current transaction is marked for rollback
if (tm.getStatus() == Status.STATUS_MARKED_ROLLBACK) {
// Don't do expensive operations, tx will rollback anyway
return;
}
// Continue with operations...
} catch (SystemException e) {
// Handle error
}
}
Distributed Transactions (XA)
// XA transactions span multiple resources (databases, JMS, etc.)
@Stateless
public class DistributedService {
@PersistenceContext(unitName = "ordersDB")
private EntityManager ordersEm;
@PersistenceContext(unitName = "inventoryDB")
private EntityManager inventoryEm;
@Inject
@JMSConnectionFactory("java:/JmsXA")
private JMSContext jmsContext;
// Single transaction across 2 databases and JMS!
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void processOrder(Order order) {
// Write to orders database
ordersEm.persist(order);
// Write to inventory database
InventoryItem item = inventoryEm.find(InventoryItem.class, order.getItemId());
item.decreaseStock(order.getQuantity());
// Send JMS message
jmsContext.createProducer().send(queue, "Order created: " + order.getId());
// All 3 operations commit or rollback together!
}
}
Two-Phase Commit (2PC)
XA transactions use 2PC protocol:
- Prepare Phase: All resources confirm they can commit
- Commit Phase: If all say yes, all commit; if any says no, all rollback
Transaction Timeout
// Set timeout for long-running transactions
@Stateless
public class BatchService {
@Resource
private UserTransaction utx;
public void processBatch() throws Exception {
// Set timeout to 5 minutes (300 seconds)
utx.setTransactionTimeout(300);
utx.begin();
try {
// Long-running batch operation
processManyRecords();
utx.commit();
} catch (Exception e) {
utx.rollback();
throw e;
}
}
}
// Or use annotation (container-specific)
@Stateless
@TransactionTimeout(value = 5, unit = TimeUnit.MINUTES)
public class SlowService {
// All methods have 5-minute timeout
}
JPA with Transactions
@Stateless
public class ProductService {
@PersistenceContext
private EntityManager em;
// CMT: transaction managed by container
public void createProduct(Product product) {
em.persist(product);
// Changes flushed and committed when method returns
}
public void updateProduct(Long id, String newName) {
Product p = em.find(Product.class, id);
p.setName(newName);
// No em.merge() needed! Entity is managed, changes auto-tracked
}
// Force immediate write to database
public void createAndFlush(Product product) {
em.persist(product);
em.flush(); // Write to DB now (still in transaction)
// product.getId() now available
System.out.println("Created with ID: " + product.getId());
}
// Read-only optimization
@TransactionAttribute(TransactionAttributeType.NOT_SUPPORTED)
public List<Product> findAll() {
return em.createQuery("SELECT p FROM Product p", Product.class)
.getResultList();
}
}
Optimistic Locking
@Entity
public class Product {
@Id
private Long id;
private String name;
@Version // Optimistic locking
private Long version;
}
@Stateless
public class ProductService {
@PersistenceContext
private EntityManager em;
public void updateProduct(Long id, String newName) {
Product p = em.find(Product.class, id);
p.setName(newName);
// If another transaction modified this product,
// OptimisticLockException is thrown on commit!
}
public void safeUpdate(Long id, String newName) {
try {
Product p = em.find(Product.class, id);
p.setName(newName);
em.flush();
} catch (OptimisticLockException e) {
// Handle concurrent modification
throw new ConcurrentModificationException("Product was modified");
}
}
}
Common Patterns
Saga Pattern (for Microservices)
// When XA transactions aren't possible (microservices)
@ApplicationScoped
public class OrderSaga {
@Inject
private OrderService orderService;
@Inject
private PaymentService paymentService;
@Inject
private InventoryService inventoryService;
public void createOrder(OrderRequest request) {
String orderId = null;
String paymentId = null;
try {
// Step 1: Create order
orderId = orderService.create(request);
// Step 2: Process payment
paymentId = paymentService.charge(request.getPaymentInfo());
// Step 3: Reserve inventory
inventoryService.reserve(request.getItems());
// Step 4: Complete order
orderService.complete(orderId);
} catch (Exception e) {
// Compensating transactions (rollback)
if (paymentId != null) {
paymentService.refund(paymentId);
}
if (orderId != null) {
orderService.cancel(orderId);
}
throw new OrderFailedException("Order creation failed", e);
}
}
}
Audit Logging (Separate Transaction)
@ApplicationScoped
public class AuditService {
@PersistenceContext
private EntityManager em;
// Always log, even if main transaction fails
@Transactional(TxType.REQUIRES_NEW)
public void logAction(String user, String action, String details) {
AuditLog log = new AuditLog();
log.setUser(user);
log.setAction(action);
log.setDetails(details);
log.setTimestamp(Instant.now());
em.persist(log);
// Commits in separate transaction
}
}
@ApplicationScoped
public class AccountService {
@Inject
private AuditService auditService;
@Transactional
public void transferMoney(String user, Long fromId, Long toId, BigDecimal amount) {
// Log attempt (persists even if transfer fails)
auditService.logAction(user, "TRANSFER_ATTEMPT",
"From: " + fromId + ", To: " + toId + ", Amount: " + amount);
try {
// Do transfer...
doTransfer(fromId, toId, amount);
auditService.logAction(user, "TRANSFER_SUCCESS", "Completed");
} catch (Exception e) {
auditService.logAction(user, "TRANSFER_FAILED", e.getMessage());
throw e;
}
}
}
Best Practices
DO:
- Use CMT by default - Let the container manage transactions
- Keep transactions short - Long transactions cause locking issues
- Use REQUIRES_NEW for audit logs - Must persist even on failure
- Use optimistic locking (@Version) - Better scalability
- Handle OptimisticLockException - Retry or notify user
- Set appropriate timeouts - Prevent hung transactions
- Use NOT_SUPPORTED for read-only - Better performance
DON'T:
- Don't catch and ignore exceptions - Transaction won't rollback
- Don't do external calls in transactions - HTTP, file I/O, etc.
- Don't hold transactions during user input - Causes blocking
- Don't forget about lazy loading - May fail after tx closes
- Don't mix CMT and BMT - Confusing and error-prone
- Don't use SERIALIZABLE isolation unless necessary - Performance impact
Summary
- JTA: Standard API for transaction management
- CMT: Container-Managed Transactions (recommended)
- BMT: Bean-Managed Transactions (manual control)
- @Transactional: CDI annotation for transaction demarcation
- REQUIRED: Join or create transaction (default)
- REQUIRES_NEW: Always new transaction (audit logs)
- NOT_SUPPORTED: No transaction (read-only)
- XA Transactions: Distributed transactions across resources
- @Version: Optimistic locking in JPA
- Saga Pattern: Compensating transactions for microservices