Spring Transaction Fundamentals
Define short service-layer transaction boundaries and reason about proxy interception, propagation, rollback rules, locks, and external side effects.
Learning objectives
- Explain how Spring applies declarative transactions through a proxy
- Design transaction boundaries that protect data without including remote calls
What it is
A database transaction groups reads and writes into one commit or rollback boundary with an isolation policy. Spring declarative transaction management uses metadata such as @Transactional and an AOP proxy around an externally invoked method to drive a transaction manager.
Why it matters
Business operations such as order creation, balance movement, or voucher redemption must not expose partial durable state. At the same time, long transactions hold connections and locks, increasing contention and failure impact.
How it works
Declarative transaction call
| Default | Spring behavior |
|---|---|
| Propagation | REQUIRED: join an existing transaction or create one |
| Isolation | Use the underlying data source default |
| Read/write | Read-write |
| Rollback | RuntimeException and Error roll back; checked Exception does not by default |
| Proxy mode | Only calls that enter through the proxy are intercepted |
Simple example
@Service
public class OrderService {
private final OrderMapper orderMapper;
private final OutboxMapper outboxMapper;
public OrderService(OrderMapper orderMapper, OutboxMapper outboxMapper) {
this.orderMapper = orderMapper;
this.outboxMapper = outboxMapper;
}
@Transactional
public long create(CreateOrderCommand command) {
OrderRow order = OrderRow.create(command);
orderMapper.insert(order);
outboxMapper.insert(OrderCreatedEvent.from(order));
return order.id();
}
}Order and outbox rows commit together. A separate relay publishes the outbox event later, so a slow or unavailable broker does not keep the database transaction open.
Backend example
@Transactional
public RedemptionResponse redeem(long voucherId, String idempotencyKey, long actorId) {
RedemptionRow existing = redemptionMapper.findByKey(actorId, idempotencyKey);
if (existing != null) return RedemptionResponse.from(existing);
int changed = voucherMapper.redeemIfIssued(voucherId, actorId);
if (changed == 0) throw new VoucherNotRedeemableException(voucherId);
RedemptionRow created = RedemptionRow.create(voucherId, actorId, idempotencyKey);
redemptionMapper.insert(created);
return RedemptionResponse.from(created);
}Production example
- 1
Before
Validate request syntax and obtain required non-database context before opening the transaction.
- 2
Inside
Read and write only the data required for one invariant; use stable lock order and explicit affected-row checks.
- 3
After commit
Perform remote calls or publish via an outbox/after-commit mechanism with its own retry and idempotency policy.
- 4
Failure
Classify conflicts, deadlocks, timeouts, and infrastructure failures; retry only safe operations with a bounded policy.
Common mistakes
- Annotating a private method and expecting proxy interception
- Calling a transactional method from another method on the same object
- Expecting checked exceptions to roll back by default
- Performing email, payment, or HTTP calls while holding database locks
- Using REQUIRES_NEW without analyzing independent commit and pool demand
- Catching an exception and returning success after the transaction is marked rollback-only
Best practices
- Put use-case transactions on concrete service methods
- Keep boundaries short and observable
- State rollback rules explicitly for checked business exceptions
- Use database constraints and atomic SQL inside the boundary
- Publish integration events with an outbox where consistency requires it
- Test actual rollback and concurrency, not only annotations
Trade-offs
| Choice | Use | Cost |
|---|---|---|
| REQUIRED | One atomic use case | Inner work shares rollback fate |
| REQUIRES_NEW | Truly independent durable work | Extra connection; outer rollback cannot undo it |
| Optimistic control | Conflicts are rare | Caller must handle retry/conflict |
| Pessimistic lock | Short high-value serialization | Blocking and deadlock risk |
Interview questions
- How does @Transactional work?
- Why does self-invocation matter?
- Which exceptions roll back by default?
- Why avoid external HTTP calls inside a transaction?
- How would you prevent a lost balance update?
- What can cause UnexpectedRollbackException?
Hands-on task
- 1
Rollback
Prove a two-write service rolls back on a runtime failure and document checked-exception behavior.
- 2
Proxy
Write a self-invocation example, observe the missing boundary, and refactor it.
- 3
Concurrency
Run simultaneous voucher redemption requests and verify one durable result with a stable response policy.
References
- Spring: Using @Transactional — https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html
- PostgreSQL Global Development Group: Transaction Isolation — https://www.postgresql.org/docs/18/transaction-iso.html
- MyBatis: MyBatis Spring Boot Starter Reference — https://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/
- Microservices.io: Pattern: Transactional Outbox — https://microservices.io/patterns/data/transactional-outbox.html
Retrieval check
Before you continue
- Explain the main idea in your own words without rereading the page.
- Name one production problem this knowledge helps prevent.
- Describe where you would apply it in a Spring Boot project.
Source metadata
Primary references checked on 2026-07-16. Version scope is stated at the top of this page.