What is JTA — and Why Does It Exist?
A transaction is a sequence of operations that the database treats as a single, indivisible unit of work. All the operations succeed and are made permanent together (commit), or all are undone (rollback) — there is no in-between. The classic example is a bank transfer: debit one account and credit another. If the debit succeeds but the credit fails halfway through, the money has vanished. Without transactional semantics, that is exactly what happens.
JTA (Jakarta Transactions API) is the Jakarta EE
specification that provides a vendor-neutral interface for controlling
transactions in a managed runtime — the same interface works whether the
transaction manager is from WildFly, WebSphere, or Payara. Its core
interfaces are UserTransaction (for manual control) and
TransactionManager (used internally by the container).
Most application code never calls these directly — instead it relies on
@TransactionAttribute (EJB) or @Transactional
(CDI) to declare transaction semantics declaratively.
/*
* The problem JTA solves — without it, two separate JDBC connections
* have no shared fate: if the second INSERT fails, the first stays committed.
*/
// WITHOUT transactions (dangerous):
connection1.prepareStatement("UPDATE accounts SET balance = balance - ? WHERE id = ?")
.executeUpdate(); // debit committed immediately
connection2.prepareStatement("UPDATE accounts SET balance = balance + ? WHERE id = ?")
.executeUpdate(); // if this throws, debit is already permanent → money gone
// WITH @Transactional (correct):
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountRepo.debit(fromId, amount); // both participate in the same transaction
accountRepo.credit(toId, amount); // exception here rolls back the debit too
} // normal return → both committed atomically
ACID Properties — What Each Actually Guarantees
ACID is widely recited and just as widely misunderstood. Each property is guaranteed by a different part of the database engine, and knowing what each one does (and does not) cover helps you reason about failure modes.
| Property | Guarantee | What enforces it | What it does NOT cover |
|---|---|---|---|
| Atomicity | All operations in the transaction succeed, or all are undone | Transaction manager (JTA) + write-ahead log | Partial failure across independently committed transactions (that's what Sagas are for) |
| Consistency | The database moves from one valid state to another — constraints and triggers are satisfied | The database (constraints, FK checks, triggers) | Business-logic consistency — that's application code's responsibility |
| Isolation | Concurrent transactions don't see each other's uncommitted changes (degree depends on isolation level) | Database locking / MVCC | Full isolation by default — the default level (READ COMMITTED in most databases) still allows non-repeatable reads and phantom reads |
| Durability | A committed transaction's effects survive a crash | Write-ahead log flushed to durable storage before the commit acknowledgement | Storage hardware failure or misconfigured fsync settings — this is an ops/infrastructure concern |
Container-Managed vs Bean-Managed Transactions
CMT — Container-Managed Transactions (always prefer this)
The container starts, commits, and rolls back the transaction on your behalf based on annotations. You declare what you want; the runtime figures out how. This is the correct default for virtually all application code.
import jakarta.ejb.Stateless;
import jakarta.ejb.TransactionAttribute;
import jakarta.ejb.TransactionAttributeType;
@Stateless
public class BankService {
@PersistenceContext
private EntityManager em;
// REQUIRED is the default — no annotation needed, but explicit is clearer
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = em.find(Account.class, fromId);
Account to = em.find(Account.class, toId);
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(fromId, amount);
// RuntimeException → container rolls back the transaction automatically
}
from.debit(amount); // dirty-checked by JPA — no em.update() needed
to.credit(amount);
// method returns normally → container commits
}
}
BMT — Bean-Managed Transactions (use only when you genuinely need it)
BMT gives you full programmatic control: you call
userTransaction.begin(), commit(), and
rollback() yourself. This is necessary when you need
fine-grained control that CMT cannot express — for example, committing
inside a loop to avoid holding a transaction open across thousands of
records, or checking the rollback status before deciding whether to
proceed.
@Stateless
@TransactionManagement(TransactionManagementType.BEAN)
public class BatchImportService {
@Resource
private UserTransaction utx;
@PersistenceContext
private EntityManager em;
public void importInChunks(List<Record> records) throws Exception {
int chunkSize = 500;
for (int i = 0; i < records.size(); i += chunkSize) {
List<Record> chunk = records.subList(i,
Math.min(i + chunkSize, records.size()));
utx.begin();
try {
chunk.forEach(em::persist);
em.flush();
utx.commit(); // commit every 500 rows — short transactions, less lock pressure
} catch (Exception e) {
utx.rollback(); // only this chunk is rolled back, not the whole import
throw e;
}
}
}
}
@Stateless EJB: the leftover transaction trapIf a BMT bean method begins a transaction and then throws an exception
before committing or rolling back, the container cannot clean up the
transaction — the connection is returned to the pool in an unknown
state. Subsequent callers that get that connection will participate
in a transaction they did not start, or hit a "connection already
has a transaction" error. Always ensure every code path through a
BMT method ends with either a commit or a rollback — a
try/finally with rollback in the finally block is the
minimum acceptable pattern.
Transaction Propagation — @TransactionAttribute and @Transactional
Every EJB method and every CDI bean method annotated with
@Transactional carries a propagation type that defines
what happens to the transaction when the method is called. The two
annotation systems express the same six types with slightly different
syntax:
// EJB (on @Stateless / @Stateful / @Singleton beans)
@TransactionAttribute(TransactionAttributeType.REQUIRED)
// CDI (on @ApplicationScoped / @RequestScoped / any CDI-managed bean)
import jakarta.transaction.Transactional;
import jakarta.transaction.Transactional.TxType;
@Transactional(TxType.REQUIRED)
| Type | Caller has no transaction | Caller has a transaction | Primary use case |
|---|---|---|---|
| REQUIRED (default) | New transaction created | Joins the existing transaction | The correct default for any method that writes to the database |
| REQUIRES_NEW | New transaction created | Caller's transaction is suspended; a new independent transaction starts. Resumes caller's on return | Audit logging that must persist even when the calling transaction rolls back; operations that must commit independently regardless of the caller's outcome |
| MANDATORY | Throws TransactionRequiredException |
Joins the existing transaction | Internal helper methods that must always be called within an existing transaction — a design contract enforced at runtime |
| SUPPORTS | Runs without a transaction | Joins the existing transaction | Reads that can work either with or without transactional context — rarely the right choice; prefer REQUIRED or NOT_SUPPORTED |
| NOT_SUPPORTED | Runs without a transaction | Caller's transaction is suspended for the duration of the call | Read-only operations or calls to external resources that should not lock database rows for the full duration of a long transaction |
| NEVER | Runs without a transaction | Throws InvalidTransactionException |
Methods that are explicitly not safe under a transaction — rarely used |
Propagation in practice — audit logging
@ApplicationScoped
public class AuditService {
@PersistenceContext
private EntityManager em;
// REQUIRES_NEW: this commits in its own transaction regardless of
// whether the caller's transaction later rolls back.
@Transactional(TxType.REQUIRES_NEW)
public void log(String user, String action, String outcome) {
em.persist(new AuditEntry(user, action, outcome, Instant.now()));
}
}
@ApplicationScoped
public class TransferService {
@Inject private AccountRepository accounts;
@Inject private AuditService audit;
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
audit.log("system", "TRANSFER_ATTEMPT", fromId + "→" + toId);
// AuditEntry committed immediately in its own tx.
accounts.debit(fromId, amount);
accounts.credit(toId, amount);
// This tx commits or rolls back — the audit entry is already permanent either way.
}
}
Rollback Rules — The Checked Exception Trap
This is the most common silent bug in transactional Jakarta EE code.
By default, @Transactional and CMT both roll back the
transaction when the method exits via a RuntimeException
(unchecked). A checked exception — one that
extends Exception but not RuntimeException —
does not trigger a rollback by default. The transaction
commits even though an exception was thrown.
@Transactional
public void processPayment(Order order) throws PaymentGatewayException {
em.persist(order); // order persisted
gateway.charge(order); // throws PaymentGatewayException (checked)
// Exception propagates up, but the transaction COMMITS.
// The order row exists in the database with no corresponding payment.
// Silent data inconsistency.
}
// THE FIX: declare rollbackOn for checked exceptions
@Transactional(rollbackOn = PaymentGatewayException.class)
public void processPayment(Order order) throws PaymentGatewayException {
em.persist(order);
gateway.charge(order); // now rolls back the transaction if this throws
}
// Or wrap in a RuntimeException to get automatic rollback
@Transactional
public void processPayment(Order order) {
em.persist(order);
try {
gateway.charge(order);
} catch (PaymentGatewayException e) {
throw new PaymentFailedException("Gateway rejected payment", e);
// PaymentFailedException extends RuntimeException → rollback triggered
}
}
@Transactional method also silently commits@Transactional
public void dangerousPattern(Order order) {
em.persist(order);
try {
gateway.charge(order);
} catch (Exception e) {
log.error("Payment failed", e);
// Swallowed exception — method returns normally — transaction COMMITS.
// Order is in the database, payment was never taken.
}
}
The fix is to either re-throw after logging, or to call
ctx.setRollbackOnly() (EJB) before returning, which
marks the transaction for mandatory rollback without requiring an
exception to escape the method boundary.
Self-invocation — the proxy bypass trap
// BROKEN: calling a @Transactional method from within the same class
@ApplicationScoped
public class OrderService {
@Transactional
public void placeOrder(OrderRequest req) {
createOrder(req); // direct 'this' call — bypasses the CDI proxy
reserveInventory(req); // @Transactional on this method is silently ignored
}
@Transactional(TxType.REQUIRES_NEW)
public void reserveInventory(OrderRequest req) {
// This was supposed to run in a new transaction. It doesn't.
// It runs in placeOrder's transaction — or no transaction if placeOrder
// wasn't called from outside either.
}
}
// FIX OPTION 1: Inject self via CDI (self-injection goes through the proxy)
@ApplicationScoped
public class OrderService {
@Inject
private OrderService self; // CDI-managed proxy of this bean
@Transactional
public void placeOrder(OrderRequest req) {
self.reserveInventory(req); // through proxy → new transaction correctly started
}
@Transactional(TxType.REQUIRES_NEW)
public void reserveInventory(OrderRequest req) { ... }
}
// FIX OPTION 2 (cleaner): Extract reserveInventory into a separate CDI bean.
// Two beans calling each other always go through proxies.
Isolation Levels — What Problems Each One Solves
Isolation controls how much one transaction can see of another's in-progress changes. More isolation means fewer data anomalies but more locking and lower throughput. Understanding which anomalies each level prevents is essential for tuning concurrent applications.
| Isolation level | Dirty read | Non-repeatable read | Phantom read | Typical use |
|---|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible | Almost never — you can read uncommitted data that was never actually committed |
| READ COMMITTED | Prevented | Possible | Possible | Default in PostgreSQL, Oracle, SQL Server — good balance for OLTP |
| REPEATABLE READ | Prevented | Prevented | Possible | Default in MySQL/InnoDB. Use when a single transaction must read the same row twice and get consistent results |
| SERIALIZABLE | Prevented | Prevented | Prevented | Financial audits, regulatory reporting — maximum consistency at the cost of significant locking overhead |
/*
* The three anomalies, concretely:
*
* DIRTY READ: Transaction A reads a row that Transaction B modified but hasn't
* committed yet. B rolls back. A is operating on data that never existed.
*
* NON-REPEATABLE READ: Transaction A reads row #42. Transaction B updates and
* commits row #42. Transaction A reads row #42 again. Gets a different value.
* Same query, different result within the same transaction.
*
* PHANTOM READ: Transaction A queries "SELECT * FROM orders WHERE total > 100".
* Transaction B inserts a new order with total = 150 and commits.
* Transaction A runs the same query again. Gets an extra row that wasn't there.
* Same query, different set of rows within the same transaction.
*/
// JPA: set isolation level on the EntityManager factory (persistence.xml)
// or programmatically via JDBC connection unwrap (Hibernate-specific):
import org.hibernate.Session;
Session session = em.unwrap(Session.class);
session.doWork(conn ->
conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE));
@Version solves non-repeatable read at the application layer without upgrading the isolation levelAdding @Version Long version to an entity makes Hibernate
add WHERE version = ? to every UPDATE. If two
transactions both read version 3 of a row and the first to commit
bumps it to version 4, the second commit matches zero rows and
Hibernate throws OptimisticLockException. This gives you
the conflict detection of REPEATABLE READ without holding database
row locks for the transaction's duration — significantly better
throughput for read-heavy workloads where conflicts are rare. See
JPA for the full @Version
example.
Flush vs Commit — What Each One Actually Does
This distinction trips up developers who are new to JPA. They are not the same operation.
@Transactional
public void createProductAndLog(Product product) {
em.persist(product);
// At this point: product exists in the persistence context (first-level cache).
// The database has NOT seen it yet. product.getId() may be null (SEQUENCE strategy
// will pre-assign the ID; IDENTITY strategy requires the INSERT to get it).
em.flush();
// NOW: Hibernate writes the SQL INSERT to the database within the current
// transaction. The row is visible to other queries in this same transaction.
// product.getId() is now populated even with IDENTITY strategy.
// The transaction is still open — other transactions cannot see this row yet.
auditLog.record(product.getId(), "CREATED");
// method returns normally → container COMMITS.
// Now the INSERT is permanent and visible to all other transactions.
}
/*
* Rule of thumb:
* - em.flush() = "write changes to the database, still within the transaction"
* - Commit = "make changes permanent and release all locks"
*
* Premature flush = unnecessary round-trips to the database.
* Missing flush when you need the generated ID = product.getId() is null.
*/
XA Transactions — Spanning Multiple Resources
An XA transaction coordinates a single atomic unit of work across multiple independent transactional resources — two databases, a database and a JMS queue, a database and a message broker. If any resource fails to commit, all are rolled back.
// XA requires XA-capable JDBC drivers and a JTA-compatible connection pool
// (e.g. Agroal with XA datasources on WildFly, or Atomikos in standalone)
@Stateless
public class OrderFulfillmentService {
@PersistenceContext(unitName = "ordersDB")
private EntityManager ordersEm;
@PersistenceContext(unitName = "inventoryDB")
private EntityManager inventoryEm;
@Inject
@JMSConnectionFactory("java:/jms/XAConnectionFactory")
private JMSContext jms;
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public void fulfillOrder(Order order) {
ordersEm.persist(order); // write to orders DB
InventoryItem item = inventoryEm.find(
InventoryItem.class, order.getItemId());
item.decreaseStock(order.getQuantity()); // write to inventory DB
jms.createProducer().send(
jms.createQueue("shipping"),
"SHIP:" + order.getId()); // enqueue JMS message
// All three resources commit atomically via 2PC, or all roll back.
}
}
Two-Phase Commit — how XA achieves atomicity
/*
* Phase 1 — PREPARE:
* Transaction Manager asks every resource manager: "Can you commit?"
* Each resource manager writes to its own write-ahead log and votes YES or NO.
* A vote of NO from any single resource means the whole transaction will abort.
*
* Phase 2 — COMMIT (or ROLLBACK):
* If ALL resource managers voted YES: TM sends COMMIT to each.
* If ANY resource manager voted NO: TM sends ROLLBACK to all.
*
* The window between Phase 1 and Phase 2 is the "in-doubt" window —
* if the TM crashes here, some resources may have committed and some not.
* Proper JTA transaction managers (Narayana on WildFly, etc.) write their
* own recovery log so they can resolve in-doubt transactions on restart.
* This is why rolling your own XA coordinator is a terrible idea.
*/
Two-phase commit adds at minimum two network round-trips to the commit path and holds locks across all participating resources until all votes are collected. Under failure, recovery can take seconds to minutes. Modern distributed systems often avoid XA entirely in favour of event-driven architectures with eventual consistency (Saga pattern, covered below). Reserve XA for cases where a single monolith touches multiple transactional resources and the operational overhead is acceptable — cross-database writes in a payment system managed by a battle-tested Jakarta EE container being the canonical legitimate use case.
The Saga Pattern — Transactions Without XA in Distributed Systems
Microservices cannot share a JTA transaction manager across service boundaries — each service has its own database and its own transaction scope. XA does not work here. The Saga pattern replaces a single distributed transaction with a sequence of local transactions, each of which publishes an event or message. If any step fails, preceding steps are undone via compensating transactions.
There are two coordination styles: choreography (each service reacts to events from the previous step, no central orchestrator) and orchestration (a dedicated saga orchestrator tells each service what to do and handles failures). Orchestration is easier to reason about and to observe.
// Orchestration-style saga: one class drives all steps and compensations
@ApplicationScoped
public class PlaceOrderSaga {
@Inject private OrderService orderService;
@Inject private PaymentService paymentService;
@Inject private InventoryService inventoryService;
public void execute(OrderRequest req) {
String orderId = null;
String paymentId = null;
String reservationId = null;
try {
// Each step commits its own local transaction independently
orderId = orderService.create(req); // step 1
paymentId = paymentService.charge(req.getPayment()); // step 2
reservationId = inventoryService.reserve(req.getItems()); // step 3
orderService.confirm(orderId); // step 4
} catch (Exception e) {
// Compensate in reverse order — each compensation is its own local tx
if (reservationId != null) inventoryService.release(reservationId);
if (paymentId != null) paymentService.refund(paymentId);
if (orderId != null) orderService.cancel(orderId);
throw new OrderFailedException("Saga rolled back: " + e.getMessage(), e);
}
}
}
Between each step, other services and queries can observe intermediate
states: an order that exists but has not yet been paid, a payment
that has been taken but whose inventory hasn't been reserved. This
is fundamentally different from a database transaction where
intermediate state is invisible to all other observers. Design your
UI, APIs, and downstream consumers to handle these transient states
explicitly — with order statuses like PENDING_PAYMENT,
PAYMENT_CONFIRMED, RESERVED — rather than
assuming any state is fully settled until the saga's final step
completes.
Best Practices and Common Pitfalls
✅ Do
- Use CMT /
@Transactionalby default — only reach for BMT when you genuinely need chunk-commit control or can't express the semantics declaratively - Declare
rollbackOn = YourCheckedException.classfor every checked exception that represents a genuine failure — the default behavior is silent data corruption - Keep transactions as short as possible — start late, end early. No external HTTP calls, no file I/O, no waiting for user input inside a transaction
- Use
@Versionoptimistic locking on entities subject to concurrent writes — better throughput than raising the isolation level - Use
REQUIRES_NEWfor audit logging — it must persist even when the calling transaction rolls back - In BMT, ensure every code path ends with either commit or rollback — use
try/finallywith rollback in the finally block
❌ Don't
- Don't catch and swallow exceptions inside a
@Transactionalmethod without also marking the transaction for rollback — the method appears to succeed and the transaction commits with corrupted state - Don't call a
@Transactionalmethod from within the same CDI bean instance — the call bypasses the proxy and the annotation is silently ignored - Don't hold a transaction open across multiple HTTP requests (think twice before using an extended persistence context) — it pins a database connection for the full time, starving the connection pool
- Don't use SERIALIZABLE isolation casually — it can cause dramatic lock contention and throughput reduction in concurrent OLTP workloads
- Don't reach for XA transactions as the default solution to distributed data — evaluate event-driven patterns and the Saga model first; XA is operationally expensive
- Don't confuse
em.flush()with commit — flush writes SQL within the transaction; only the container commit makes changes permanent and visible to other transactions
Interview Questions
Q: What are the ACID properties of a transaction?
Atomicity — all operations succeed or all are undone. Consistency —
the database moves from one valid state to another (constraints satisfied).
Isolation — concurrent transactions don't see each other's uncommitted
changes (to a degree that depends on the isolation level). Durability —
a committed transaction's changes survive a crash.
Q: What is the difference between @TransactionAttribute(REQUIRED) and @TransactionAttribute(REQUIRES_NEW)?
REQUIRED joins an existing transaction if one is active, or creates a
new one if not. REQUIRES_NEW always creates a brand-new transaction,
suspending the caller's transaction for the duration of the method.
The new transaction commits or rolls back independently of the caller's.
REQUIRES_NEW is the correct choice for operations that must commit
regardless of what the caller does — audit logging being the canonical
example.
Q: What is @Transactional and how is it different from @TransactionAttribute?
@Transactional is a CDI interceptor annotation from
jakarta.transaction that can be applied to any CDI-managed
bean. @TransactionAttribute is the EJB annotation from
jakarta.ejb and only works on EJB session beans. Both
express the same six propagation types; the EJB version uses
TransactionAttributeType constants while the CDI version
uses TxType. In modern Jakarta EE and Spring code,
@Transactional is the preferred annotation.
Q: A @Transactional method catches a checked exception, logs it, and returns normally. A database constraint violation caused the exception. What happens to the transaction, and why is this a production incident waiting to happen?
The transaction commits. @Transactional rolls back only
on unchecked exceptions (or exceptions explicitly declared in
rollbackOn). If the exception was swallowed inside the
method and the method returns normally, the container sees a clean
return and commits the transaction — even if the persistence context
is now in an inconsistent state relative to database constraints. The
result is a committed transaction containing partial writes: some
operations succeeded, others silently failed. Downstream reads will
see corrupted or inconsistent data with no error logged at the
transaction boundary. The fix is to never swallow exceptions inside
a transactional method without either re-throwing or explicitly calling
setRollbackOnly().
Q: Explain the Two-Phase Commit protocol and its failure modes.
2PC has two phases. In the Prepare phase, the Transaction Manager asks
every enlisted resource manager whether it can commit. Each writes its
intent to a durable log and votes YES or NO. In the Commit phase, if
all voted YES the TM sends COMMIT to all; if any voted NO it sends
ROLLBACK to all. The critical failure mode is the "in-doubt window":
if the TM crashes after all participants have voted YES but before it
has sent the COMMIT, those participants hold locks indefinitely waiting
for a decision they will never receive. Recovery requires the TM to
consult its own recovery log on restart and replay the commit decision.
This is why JTA-capable transaction managers like Narayana maintain a
persistent object store — and why a hand-rolled distributed transaction
coordinator is never appropriate for production.
Q: When would you choose the Saga pattern over XA transactions, and what is its main consistency trade-off?
The Saga pattern is appropriate when the participating services cannot
share a JTA transaction manager — typically in microservices where each
service owns its own database. XA is not viable across autonomous
network services because it requires all participants to be available
simultaneously for the 2PC protocol to complete. The Saga replaces one
distributed atomic transaction with a sequence of local transactions
coordinated through events or an orchestrator, with compensating
transactions for rollback. The main trade-off is eventual consistency:
between any two steps of the saga, other systems can observe
intermediate states (an order with payment taken but inventory not yet
reserved). The system is never in the clean "all or nothing" state that
a single ACID transaction provides — it moves through a series of
partial states, each of which must be explicitly designed for and
handled by downstream consumers.