All or nothing
Think of a bank transfer: take 100 EUR from Ana, add 100 EUR to Marko. If the server crashes between the two steps, the money must not vanish. A transaction groups several database changes into one unit:
- All or nothing (atomic): either every change is saved, or none is. If something fails, the database rolls back to how it was before.
- Consistent: rules like “balance can’t be negative” hold before and after.
- Isolated: other requests don’t see your half-done work.
- Durable: once it says “saved” (commit), it survives a crash.
Those four are often called ACID. In Spring and in Jakarta EE, you mark a service method @Transactional, and everything it does in the database becomes one transaction.
@Transactional wraps a method in a proxy that binds a connection to the thread, begins a transaction, and commits on normal return or rolls back on failure, according to rollback rules (by default only unchecked exceptions and Errors). Nested calls join the outer transaction (REQUIRED), so the outermost boundary decides.
The interesting parts are what the annotation can’t do: it doesn’t prevent concurrent anomalies (lost updates and write skew under the default READ COMMITTED), it ignores self-invocation, a caught inner failure can poison the whole transaction, and anything outside the database (HTTP calls, emails, Kafka) isn’t rolled back. The Detailed version covers locking, isolation levels, propagation and the transactional outbox.
@Transactional (Jakarta Transactions, jakarta.transaction) is a CDI interceptor: it begins a JTA transaction, or joins the caller’s (TxType.REQUIRED), and commits on normal return or rolls back on failure. The default rollback rules match Spring’s: unchecked exceptions roll back, checked ones commit, unless you list them in rollbackOn / dontRollbackOn. There’s no readOnly, timeout or isolation attribute: isolation is configured on the datasource. EJB @Stateless beans are transactional by default, with similar rules.
The interesting parts are what the annotation can’t do: it doesn’t prevent concurrent anomalies (lost updates and write skew under the default READ COMMITTED), a caught inner failure can poison the whole transaction, and anything outside the database (HTTP calls, emails, Kafka) isn’t rolled back. The Detailed version covers locking, isolation levels, propagation and the transactional outbox.
@Service
class TransferService(private val accounts: AccountRepository) {
@Transactional
fun transfer(fromId: Long, toId: Long, amountCents: Long) {
val from = accounts.findById(fromId).orElseThrow()
val to = accounts.findById(toId).orElseThrow()
if (from.balanceCents < amountCents) {
throw InsufficientFundsException(fromId) // a RuntimeException: rolls back
}
from.withdraw(amountCents) // both changes are written at commit,
to.deposit(amountCents) // or neither is
}
}@Service
public class TransferService {
private final AccountRepository accounts;
public TransferService(AccountRepository accounts) {
this.accounts = accounts;
}
@Transactional
public void transfer(long fromId, long toId, long amountCents) {
Account from = accounts.findById(fromId).orElseThrow();
Account to = accounts.findById(toId).orElseThrow();
if (from.getBalanceCents() < amountCents) {
throw new InsufficientFundsException(fromId); // a RuntimeException: rolls back
}
from.withdraw(amountCents); // both changes are written at commit,
to.deposit(amountCents); // or neither is
}
}Spring starts a transaction when transfer is called from another bean (a controller, say) and ends it when the method returns:
- returns normally → commit: both balances are saved,
- throws a
RuntimeException→ rollback: neither is saved, even ifwithdrawalready ran in memory.
Put @Transactional on service methods that represent one business action, not on controllers or on every repository call.
@ApplicationScoped
public class TransferService {
@PersistenceContext
EntityManager em;
@Transactional // jakarta.transaction.Transactional
public void transfer(long fromId, long toId, long amountCents) {
Account from = em.find(Account.class, fromId);
Account to = em.find(Account.class, toId);
if (from.getBalanceCents() < amountCents) {
throw new InsufficientFundsException(fromId); // a RuntimeException: rolls back
}
from.withdraw(amountCents); // both changes are written at commit,
to.deposit(amountCents); // or neither is
}
}Kotlin note
Jakarta EE examples are in Java, the language almost all Jakarta EE code is written in. The same annotations work from Kotlin with the allopen (for CDI beans) and kotlin-jpa compiler plugins. Kotlin has no checked exceptions, but the rollback rules below still look at the exception’s class.
The runtime starts a transaction when transfer is called and ends it when the method returns:
- returns normally → commit: both balances are saved,
- throws a
RuntimeException→ rollback: neither is saved, even ifwithdrawalready ran in memory.
Put @Transactional on service methods that represent one business action, not on REST resources or on every query.
Propagation and boundaries
REQUIRED(default) joins an existing transaction: an inner failure that escapes a@Transactionalboundary marks the whole transaction rollback-only, even if the outer code catches it (puzzle below).REQUIRES_NEWsuspends the outer transaction and opens a second one on a second connection: right for audit logs that must survive a rollback, risky under load (each request needs two pooled connections, which can deadlock the pool).- Self-invocation and visibility: annotations work only through the proxy, on calls from other beans (Spring 6 proxies also handle
protectedand package-private methods, but neverthis.method()). - Keep transactions short: no remote calls, no file uploads, no user think time inside. A transaction holds a pooled connection and row locks for its whole duration.
- Side effects outside the database aren’t rolled back: emails, HTTP calls and Kafka messages need
@TransactionalEventListener(phase = AFTER_COMMIT)or a transactional outboxTransactional outboxSaving an outgoing message in the same database transaction as the data change, and publishing it afterwards.Open in glossary → (the exercise below).
TxType.REQUIRED(default) joins an existing transaction: aRuntimeExceptionthat escapes an inner@Transactionalmethod marks the whole transaction rollback-only, even if the outer code catches it (puzzle below).TxType.REQUIRES_NEWsuspends the outer transaction and runs a second one, on a second connection: right for audit logs that must survive a rollback, risky under load (each request needs two pooled connections, which can exhaust the pool).- Self-invocation: like Spring’s proxies, standard CDI applies interceptors only to calls that come through the bean’s proxy, from other beans:
this.method()skips@Transactional. Quarkus also intercepts self-invocation, as a documented non-standard feature, so code that relies on it breaks when it moves to another runtime. Private methods are never intercepted. - Keep transactions short: no remote calls, no file uploads, no user think time inside. A transaction holds a pooled connection and row locks for its whole duration. JTA transactions also have a timeout (often 300 seconds by default), configured on the runtime.
- Side effects outside the database aren’t rolled back: emails, HTTP calls and Kafka messages need a transactional observer,
@Observes(during = TransactionPhase.AFTER_SUCCESS)on a CDI event, or a transactional outboxTransactional outboxSaving an outgoing message in the same database transaction as the data change, and publishing it afterwards.Open in glossary → (the exercise below).
A caught exception that still rolls back
Predict the output
loyalty.addPoints is @Transactional in another bean and throws IllegalStateException. What happens?
@Service
class OrderService(private val orders: OrderRepository, private val loyalty: LoyaltyService) {
@Transactional
fun place(order: Order) {
orders.save(order)
try {
loyalty.addPoints(order) // @Transactional in LoyaltyService
} catch (e: IllegalStateException) {
log.warn("No loyalty points this time", e)
}
}
}@Service
public class OrderService {
@Transactional
public void place(Order order) {
orders.save(order);
try {
loyalty.addPoints(order); // @Transactional in LoyaltyService
} catch (IllegalStateException e) {
log.warn("No loyalty points this time", e);
}
}
}addPoints joins the outer transaction (REQUIRED). When the exception leaves its proxy, Spring marks the shared transaction rollback-only, even though place() catches the exception. At the end of place(), Spring tries to commit, finds the flag, rolls back, and throws UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only. The caller gets an error, and the order is gone. If the inner work is optional, run it with REQUIRES_NEW, mark noRollbackFor, or (best) do it after commit through an event.
Predict the output
loyalty.addPoints is @Transactional in another bean and throws IllegalStateException. What happens?
@ApplicationScoped
public class OrderService {
@Inject LoyaltyService loyalty;
@PersistenceContext EntityManager em;
@Transactional
public void place(Order order) {
em.persist(order);
try {
loyalty.addPoints(order); // @Transactional in LoyaltyService
} catch (IllegalStateException e) {
log.warn("No loyalty points this time", e);
}
}
}addPoints joins the outer transaction (TxType.REQUIRED). When the RuntimeException leaves its interceptor, which didn't start the transaction, the interceptor marks the shared transaction rollback-only, even though place() catches the exception. At the end of place(), the commit finds the flag and rolls back: the caller gets an exception (a RollbackException, usually wrapped in a TransactionalException), and the order is gone. If the inner work is optional, run it with TxType.REQUIRES_NEW, list the exception in dontRollbackOn, or (best) do it after commit through a transactional observer.
- Default propagation joins the caller’s transaction, so an inner failure marks the whole transaction rollback-only, even if the outer method catches it.
REQUIRES_NEWsuspends and starts a separate one. - Transactions apply through a proxy: self-invocation skips them (Spring and standard CDI).
Check this part
What does "atomic" mean for a transaction?
Where should @Transactional usually go?
An inner @Transactional bean throws, and the outer method catches it. What happens at the outer commit?
An audit entry must be saved even when the business action fails. Which propagation does the audit method need?