Why this matters
- Without transactions, a BookStore order that deducts inventory and creates an order record can leave inconsistent data if the second step fails.
- Propagation and isolation levels determine how nested service calls interact — wrong settings cause subtle bugs.
@Transactionalonly works on Spring-managed beans called through the proxy — self-invocation bypasses it.
Basic usage
@Service
public class OrderService {
private final BookRepository bookRepository;
private final OrderRepository orderRepository;
@Transactional
public Order placeOrder(Long bookId, int quantity) {
Book book = bookRepository.findById(bookId)
.orElseThrow(() -> new BookNotFoundException(bookId));
if (book.getStock() < quantity) {
throw new InsufficientStockException(bookId, quantity);
}
book.setStock(book.getStock() - quantity);
bookRepository.save(book);
Order order = new Order(book, quantity);
return orderRepository.save(order);
}
}
If orderRepository.save() fails, the stock deduction rolls back automatically.
Read-only transactions
@Service
@Transactional(readOnly = true)
public class BookService {
public List<Book> findAll() {
return repository.findAll();
}
@Transactional // overrides class-level readOnly for writes
public Book create(CreateBookRequest request) {
return repository.save(new Book(...));
}
}
readOnly = true hints Hibernate to skip dirty checking and can route to read replicas.
Propagation levels
Propagation behaviours
REQUIRED(default) — Join existing transaction or create a new one.REQUIRES_NEW— Always create a new transaction; suspend the current one.NESTED— Nested transaction with savepoint; partial rollback possible.SUPPORTS— Join if exists, run non-transactionally otherwise.NOT_SUPPORTED— Suspend current transaction, run without one.MANDATORY— Must run inside an existing transaction or throw exception.NEVER— Must not run inside a transaction or throw exception.
REQUIRES_NEW example
Audit logging that must persist even if the main transaction rolls back:
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAction(String action, String details) {
auditRepository.save(new AuditLog(action, details, Instant.now()));
}
}
The audit record commits independently of the calling transaction.
Rollback rules
By default, @Transactional rolls back only on unchecked exceptions (RuntimeException). Checked exceptions commit:
@Transactional(rollbackFor = {InsufficientStockException.class})
public Order placeOrder(...) throws InsufficientStockException { ... }
Or globally:
@Transactional(rollbackFor = Exception.class)
Isolation levels
@Transactional(isolation = Isolation.READ_COMMITTED)
public Book updatePrice(Long id, BigDecimal newPrice) { ... }
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ_UNCOMMITTED | Possible | Possible | Possible |
| READ_COMMITTED | Prevented | Possible | Possible |
| REPEATABLE_READ | Prevented | Prevented | Possible |
| SERIALIZABLE | Prevented | Prevented | Prevented |
READ_UNCOMMITTED
Dirty readPossibleNon-repeatable readPossiblePhantom readPossibleREAD_COMMITTED
Dirty readPreventedNon-repeatable readPossiblePhantom readPossibleREPEATABLE_READ
Dirty readPreventedNon-repeatable readPreventedPhantom readPossibleSERIALIZABLE
Dirty readPreventedNon-repeatable readPreventedPhantom readPrevented
PostgreSQL defaults to READ_COMMITTED. Only raise isolation when you have proven concurrency issues.
Programmatic transactions
When annotations are impractical:
@Service
public class BulkImportService {
private final TransactionTemplate transactionTemplate;
public BulkImportService(PlatformTransactionManager txManager) {
this.transactionTemplate = new TransactionTemplate(txManager);
}
public int importBooks(List<CreateBookRequest> books) {
return transactionTemplate.execute(status -> {
int count = 0;
for (CreateBookRequest req : books) {
repository.save(new Book(req.title(), req.isbn(), req.price()));
count++;
}
return count;
});
}
}
Transaction boundaries in BookStore
| Layer | Transactional? | Why |
|---|---|---|
| Controller | No | HTTP mapping only |
| Service | Yes | Business logic + data consistency |
| Repository | Inherited | Participates in caller's transaction |
Controller
Transactional?NoWhyHTTP mapping onlyService
Transactional?YesWhyBusiness logic + data consistencyRepository
Transactional?InheritedWhyParticipates in caller's transaction
Quick recall
Everything you need if you only revisit this box.
@Transactionalon service methods ensures all-or-nothing database operations.readOnly = trueoptimises read paths and enables read-replica routing.- Propagation REQUIRED joins or creates; REQUIRES_NEW creates an independent transaction.
- Default rollback is on unchecked exceptions only — use
rollbackForfor checked ones. - Self-invocation and private methods bypass the transactional proxy.
- Keep transaction boundaries in the service layer, never in controllers or repositories.
Test yourself
Answer these before moving on — recall is what makes it stick.