What @Transactional Really Does
@Transactional is a declarative transaction boundary. Instead of the seven lines of boilerplate every JDBC tutorial opens with — connection.setAutoCommit(false), the try block, the explicit commit(), the rollback() in catch, the close() in finally — you annotate the method and Spring wires the same lifecycle around it at runtime. The method body shrinks to pure business logic; the transactional ceremony moves into an AOP proxy that fires before and after every invocation.
Behind the annotation sits a small machinery: a PlatformTransactionManager that knows how to start, commit, and roll back; a TransactionDefinition carrying propagation, isolation, timeout, and rollback rules; and a TransactionStatus exposing the current state. The proxy looks at the annotation's attributes, builds a TransactionDefinition, asks the manager for a TransactionStatus, runs your method, and then either commits or rolls back depending on whether anything escaped.
The reason @Transactional is one of the densest topics on the certification is that everything it does — the propagation rules, the rollback policy, the read-only optimisation — interacts with the proxy mechanism. Most "why didn't it work?" bugs are not bugs in the transaction manager. They are misunderstandings of the proxy that holds the annotation.
On the 2V0-72.22 exam, @Transactional falls under the Data Management section, which carries approximately 10-12% of all questions. Expect 2-4 questions across propagation, rollback rules, and the self-invocation pitfall.
How It Works Under the Hood: The Proxy Model
In the default mode — the one the exam tests — @Transactional is implemented on top of Spring AOP: the container wraps the bean in a proxy and around-advice runs before and after each invocation. Spring also supports an AspectJ mode (@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)) that uses load-time or compile-time weaving — that one does rewrite bytecode and lifts most of the proxy limitations, but it is rarely used in practice and almost never the answer on the exam.
Two pieces of plumbing sit behind the proxy. The first is TransactionSynchronizationManager, a Spring-internal class that holds the active transaction state in ThreadLocal — connection holders, the read-only flag, registered synchronizations. This is how Spring binds "the current transaction" to the current thread; it is also why REQUIRES_NEW has to physically suspend the outer transaction before opening a new one. The second is the PlatformTransactionManager itself, which knows how to begin, commit, and roll back against a specific resource type.
The interceptor delegates the actual JDBC / JPA work to a PlatformTransactionManager. Three concrete implementations cover almost every project, and Spring Boot auto-configures one based on what is on the classpath (plain Spring requires explicit configuration):
| Transaction Manager | Backing Resource | Used When |
|---|---|---|
DataSourceTransactionManager | A single JDBC DataSource | You use JdbcTemplate, MyBatis, or any pure-JDBC stack. |
JpaTransactionManager | An EntityManagerFactory | You use JPA / Hibernate / Spring Data JPA. |
JtaTransactionManager | A JTA UserTransaction | You span multiple resources (XA: DB + JMS) and need two-phase commit. |
Two more types complete the picture. TransactionDefinition carries the propagation, isolation, timeout, read-only, and rollback metadata — it is the read-only view of your annotation attributes. TransactionStatus is what the manager hands back to the proxy: a handle on the current transaction that exposes isNewTransaction(), hasSavepoint(), and setRollbackOnly().
When the proxy intercepts a call, it asks the manager for a TransactionStatus (which may start a new transaction, join an existing one, or suspend one — depending on propagation), invokes the target method, and on return either calls manager.commit(status) or manager.rollback(status). The rollback decision uses the rule set in rollbackFor / noRollbackFor against whatever exception escaped.
If you are unsure how proxying works in Spring, read the Spring AOP and Pointcut Expressions guide first — every @Transactional gotcha on the exam is really an AOP proxy gotcha. Proxies themselves are created during the bean lifecycle, in BeanPostProcessor.postProcessAfterInitialization().
The Two @Transactional Annotations
There are two annotations called @Transactional, both supported by Spring, and the exam regularly tests whether you can tell them apart at the import line. Spring respects both, but with subtly different attribute sets — most importantly different keywords for overriding rollback rules.
| Annotation | Package | Supported attributes | Rollback keyword |
|---|---|---|---|
| Spring's | org.springframework.transaction.annotation.Transactional | propagation, isolation, timeout, readOnly, rollbackFor, noRollbackFor, value (qualifier) | rollbackFor / noRollbackFor |
| Jakarta's | jakarta.transaction.Transactional (formerly javax.transaction.Transactional) | value (propagation enum), rollbackOn, dontRollbackOn | rollbackOn / dontRollbackOn |
The Spring annotation is the richer one — it is the only one that supports isolation, timeout, and readOnly in the way Spring's docs describe. The Jakarta annotation comes from Jakarta EE / JTA; Spring honours it for portability but it has a smaller attribute set and a different vocabulary for the override rules. The two cannot be mixed on the same method.
One subtle but exam-relevant gap: Jakarta's Transactional.TxType enum has six values — MANDATORY, NEVER, NOT_SUPPORTED, REQUIRED, REQUIRES_NEW, SUPPORTS. There is no NESTED. Spring's Propagation enum has all seven. A snippet that imports jakarta.transaction.Transactional and tries to use Transactional.TxType.NESTED will not compile.
The timeout attribute on the Spring annotation is in seconds (not milliseconds), defaulting to -1 which means "use the underlying resource's default". It is enforced on JDBC drivers that support Statement.setQueryTimeout and on JPA queries that propagate it; not every database honours it identically.
import org.springframework.transaction.annotation.Transactional;
@Transactional(rollbackFor = PaymentException.class) // Spring — rollbackFor
public void chargeSpring(Order o) throws PaymentException { /* ... */ }import jakarta.transaction.Transactional;
@Transactional(rollbackOn = PaymentException.class) // Jakarta — rollbackOn
public void chargeJakarta(Order o) throws PaymentException { /* ... */ }Exam tip: Questions almost always show the Spring annotation (
org.springframework.transaction.annotation.Transactional). If a snippet usespropagation,isolation,timeout, orreadOnly, it is the Spring one — those attributes do not exist on the Jakarta annotation.
Exam tip: Mixing the rollback keywords is the trap. The Jakarta annotation does not understand
rollbackFor; the Spring annotation does not understandrollbackOn. The compiler will not warn you — the attribute is simply ignored on the wrong annotation and your override silently disappears.
Exam tip: Jakarta's
Transactional.TxTypehas noNESTEDvalue. If a snippet usesjakarta.transaction.TransactionalwithNESTED, it does not compile — and the answer that claims it "runs with a savepoint" is wrong.
Where You Can Put It
@Transactional can sit on a class, on an interface, or on individual methods. The proxy resolves the effective metadata at invocation time: a method-level annotation overrides class-level defaults, and a class-level one applies to every method that does not declare its own. This is convenient for repositories where most methods should be read-only and a few should not.
What the annotation cannot do is intercept calls that never reach the proxy. Three method shapes are silently ignored: private methods (the proxy cannot see them at all), non-public methods like protected or package-private (skipped by default), and final methods when CGLIB is the proxy strategy (CGLIB subclasses cannot override final).
@Service
@Transactional(readOnly = true) // class-level default
public class OrderService {
public Order findById(Long id) { /* read-only TX */ }
@Transactional // overrides — full read-write TX
public Order placeOrder(NewOrderRequest req) { /* ... */ }
@Transactional
private void audit(Order o) { /* SILENTLY IGNORED — private method */ }
@Transactional
final void close(Order o) { /* IGNORED under CGLIB — final method */ }
}There is no warning at startup, no error at runtime — the method simply runs without any transactional advice. The findById call commits or rolls back as expected because it is public and not final; placeOrder upgrades the class-level read-only setting to a full read-write transaction; audit and close execute in whatever transactional context the caller happens to have, or none at all.
Exam tip:
@Transactionalon aprivatemethod is silently ignored. Proxies — JDK dynamic or CGLIB — cannot intercept a method that the JVM resolves through direct bytecode dispatch.
Exam tip:
@Transactionalon afinalmethod is ignored under CGLIB proxies. CGLIB works by subclassing the target; it cannot override afinalmethod, so the advice never fires. JDK dynamic proxies (interface-based) sidestep this because they implement the interface, not the class.
Exam tip:
@Transactionalonprotectedor package-private methods is also ignored by default. Spring'sAnnotationTransactionAttributeSourceis configured withpublicMethodsOnly = trueout of the box — non-public methods are skipped regardless of proxy type.
Exam tip:
@Transactionalon an interface only works with JDK dynamic proxies. CGLIB proxies subclass the implementation class, not the interface, so they do not see the interface-level annotation. The safe rule: put@Transactionalon the implementation class, not the interface.
Propagation — All Seven Values
Propagation determines what the proxy does when a transactional method is called from another transactional context. The default — REQUIRED — is the one that intuitively matches "wrap this in a transaction". The other six exist for the cases where you need to opt out of the surrounding transaction, suspend it, or force its presence.
| Propagation | If a TX exists | If no TX exists | Typical use |
|---|---|---|---|
REQUIRED (default) | Join the existing TX | Start a new TX | Default for service methods |
REQUIRES_NEW | Suspend the outer TX, start a new independent TX | Start a new TX | Audit logging that must commit even if caller rolls back |
SUPPORTS | Join the existing TX | Run without a TX | Helper methods that work either way |
NOT_SUPPORTED | Suspend the existing TX, run without a TX | Run without a TX | Long-running non-DB work inside a transactional caller |
MANDATORY | Join the existing TX | Throw IllegalTransactionStateException | Enforce that the caller already started a TX |
NEVER | Throw IllegalTransactionStateException | Run without a TX | Enforce no surrounding TX |
NESTED | Create a savepoint inside the existing TX | Start a new TX | Partial rollback within a parent transaction |
The three most-tested values behave very differently when an outer transactional method calls an inner transactional method. Visualised:
- ►1 transaction. Rollback anywhere = full rollback.
- ►Inner cannot commit independently of outer.
- ►2 independent transactions, separate connections.
- ►Inner commits even if outer rolls back (audit logs).
- ►1 transaction. Inner can roll back to the savepoint without aborting outer.
- ⚠Requires JDBC savepoint support (DataSourceTransactionManager). NOT supported by JpaTransactionManager out of the box.
REQUIRED is the workhorse. The inner method joins the outer transaction; there is one logical and one physical transaction; if either side throws, the whole thing rolls back. SUPPORTS is the opposite of opinionated: it works inside a transaction if one exists, otherwise it just runs. MANDATORY and NEVER exist purely for defensive programming — they fail fast if the surrounding context is not what you expect.
NESTED is the one most candidates misread. It uses JDBC savepoints inside the outer transaction. If the inner block throws, only the savepoint is rolled back; the outer transaction can continue. It requires a JDBC driver that supports savepoints and — critically — a DataSourceTransactionManager. JpaTransactionManager does not support NESTED out of the box.
REQUIRES_NEW: connection-level view
REQUIRES_NEW is the most surprising of the seven, and the exam likes it because the surprise hides in the plumbing. The suspension is not a logical book-keeping trick at the Spring layer — it happens at the resource layer. The outer transaction's connection is unbound from the current thread and parked in the TransactionSynchronizationManager; a second, physically distinct connection is checked out of the pool for the inner transaction.
- ►Two independent connections are checked out from the pool.
- ►Inner commits independently — useful for audit logs that must persist.
- ►If outer and inner write the same row, you can self-deadlock.
Two physical connections, two independent transactions. The inner one can commit even if the outer one rolls back — they hold separate locks, they see the database through separate snapshots. The cost is that they cannot see each other's uncommitted changes, and if the outer transaction holds a row lock that the inner one tries to acquire, the same thread will deadlock against itself. The DB lock manager sees two distinct sessions; it does not know they are the same JVM thread.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAuditLog(AuditEvent event) {
// Runs on a separate connection. Commits independently of the caller.
auditRepository.save(event); // Will commit even if the caller throws.
}Exam tip:
REQUIREDis the default. If a question shows@Transactionalwithout an explicitpropagationattribute, assumePropagation.REQUIRED.
Exam tip:
REQUIRES_NEWis not the same as nested. It suspends the outer transaction and uses a second connection;NESTEDreuses the outer connection with a savepoint. The exam routinely offers both as distractors.
Exam tip:
NESTEDis not supported byJpaTransactionManagerout of the box. You will getNestedTransactionNotSupportedExceptionat runtime. If the code uses JPA and you seeNESTED, that is the wrong answer.
Isolation Levels & Concurrency Anomalies
Isolation controls what one transaction can see of another transaction's uncommitted (or in-flight) changes. Higher isolation prevents more anomalies but takes more locks; lower isolation is faster but exposes you to surprises. The exam tests the four standard levels and the three anomalies they trade off against each other.
| Isolation | 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 |
The three anomalies in one line each:
- Dirty read — TX A reads a row that TX B has modified but not yet committed; if B rolls back, A saw a value that never officially existed.
- Non-repeatable read — TX A reads a row, TX B updates and commits the same row, TX A reads it again and sees a different value within the same transaction.
- Phantom read — TX A runs the same range query twice (
WHERE age > 30); TX B inserts a new matching row and commits between the two reads; A's second query returns extra rows that "appeared from nowhere".
Spring also exposes an Isolation.DEFAULT value, which delegates to whatever the database itself defines as its default. This is not a constant level — it depends on the engine. PostgreSQL defaults to READ_COMMITTED; MySQL (InnoDB) defaults to REPEATABLE_READ; Oracle defaults to READ_COMMITTED. The same Spring code on the same SQL will exhibit different anomalies depending on the database underneath.
@Transactional(isolation = Isolation.REPEATABLE_READ, timeout = 30)
public Report buildReport(LocalDate from, LocalDate to) {
// The proxy passes REPEATABLE_READ to the TransactionManager, which calls
// Connection.setTransactionIsolation(TRANSACTION_REPEATABLE_READ) on the
// underlying JDBC connection before the method body runs. timeout = 30
// means 30 SECONDS (not millis) — propagated to Statement.setQueryTimeout
// where the driver supports it.
return reportRepository.buildBetween(from, to);
}Exam tip:
Isolation.DEFAULTis notREAD_COMMITTEDeverywhere. It means "use the database's own default", which differs between PostgreSQL (READ_COMMITTED), MySQL/InnoDB (REPEATABLE_READ), and Oracle (READ_COMMITTED). The exam may test the same code on a different DB.
Rollback Rules — The Checked Exception Trap
By default, Spring rolls back on RuntimeException and Error. It does NOT roll back on checked exceptions. This single rule, lifted from EJB heritage and never relaxed, accounts for more wrong answers on the exam than any other transactional behaviour. The proxy inspects the exception that escapes the method; if it is unchecked or an Error, rollback; otherwise, commit.
@Transactional
public void chargeCustomer(Order o) throws PaymentException {
repository.save(o);
paymentGateway.charge(o); // throws PaymentException (checked)
// The save above is COMMITTED — checked exceptions do not roll back by default.
}To override the default, declare the exception types explicitly. Both directions exist:
// Force rollback on a checked exception
@Transactional(rollbackFor = PaymentException.class)
public void chargeCustomer(Order o) throws PaymentException { /* ... */ }
// Prevent rollback on a runtime exception
@Transactional(noRollbackFor = ValidationException.class)
public void validate(Form f) { /* ... */ }rollbackFor accepts a class or list of classes; rollback fires on any subclass too. noRollbackFor is its inverse — useful when a RuntimeException is part of normal control flow and should not abort the transaction.
The second half of this trap is the catch-and-swallow pattern. The proxy only ever rolls back if an exception escapes the method body. If you catch the exception inside, log it, and return normally, the proxy sees a successful return and commits everything that came before. This is rarely what you want, but it is silent — no warning, no log line.
@Transactional
public void chargeCustomer(Order o) {
repository.save(o);
try {
paymentGateway.charge(o);
} catch (PaymentException e) {
log.error("Payment failed", e);
// The save above is COMMITTED — exception never escaped, no rollback.
}
}If you must catch and still roll back, mark the transaction rollback-only on the TransactionStatus. Inside an @Transactional method, the easiest way to get a handle on the current status is TransactionAspectSupport.currentTransactionStatus() (a static call that reads the active status from the TransactionSynchronizationManager). The transaction manager will honour the flag at commit time and roll back instead.
@Transactional
public void chargeCustomer(Order o) {
repository.save(o);
try {
paymentGateway.charge(o);
} catch (PaymentException e) {
// Marks the current TX rollback-only. The proxy will roll back at commit time
// and throw UnexpectedRollbackException to the caller.
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
log.error("Payment failed; transaction marked rollback-only", e);
}
}Exam tip: Default rollback covers only
RuntimeExceptionandError. Checked exceptions commit unless you setrollbackFor. This is the single most-missed rule on the exam.
The Self-Invocation Pitfall
The most common "why didn't my transaction start?" bug in Spring code is self-invocation. Because @Transactional works through a proxy, the advice fires only when the call passes through the proxy. A call from one method to another inside the same bean uses the this reference — which points at the raw target object, not the proxy — and bypasses the interceptor entirely.
@Service
public class UserService {
public void registerAndNotify(User u) {
save(u); // self-invocation: bypasses proxy, no TX
sendWelcome(u);
}
@Transactional
public void save(User u) {
repository.save(u);
}
private void sendWelcome(User u) { /* ... */ }
}When external code calls userService.registerAndNotify(), the call goes through the proxy (no @Transactional on registerAndNotify, so no transaction). Inside that method, save(u) is a direct dispatch on this. The proxy never sees it. The @Transactional on save is meaningless from this invocation path.
thisregisterAndNotify() {
this.save(user); // direct call
// ▲ Spring proxy is NOT in the call path
// @Transactional advice never fires
}- ⚠No transaction is started.
- ►Same behaviour as if you had no annotation at all.
registerAndNotify() {
userService.save(user); // through Spring proxy
// ▲ proxy intercepts → begins TX
// advice runs, transaction is real
}- ✓Transaction starts as expected.
- ►Inject the bean into the caller, or split into two beans.
There are three accepted fixes, in order of preference:
(a) Split into two beans. Move the transactional method to a separate @Service. The outer bean injects the inner one, and the call goes through the proxy.
@Service
public class UserRegistrationService {
private final UserService userService;
public UserRegistrationService(UserService userService) {
this.userService = userService;
}
public void registerAndNotify(User u) {
userService.save(u); // through the proxy — @Transactional fires
sendWelcome(u);
}
private void sendWelcome(User u) { /* ... */ }
}(b) Self-inject the bean. Inject the bean's own proxy back into itself and use it instead of this. Works, but reviewers tend to flag it; it is a workaround, not a design.
@Service
public class UserService {
@Autowired
private UserService self; // the proxy, not the raw object
public void registerAndNotify(User u) {
self.save(u); // through the proxy — @Transactional fires
}
@Transactional
public void save(User u) { repository.save(u); }
}(c) AopContext.currentProxy(). With @EnableAspectJAutoProxy(exposeProxy = true) Spring publishes the current proxy on a thread-local. You can fetch it inside the method. The most invasive fix and rarely the right one.
@EnableAspectJAutoProxy(exposeProxy = true)
@Configuration
public class AppConfig {}
@Service
public class UserService {
public void registerAndNotify(User u) {
((UserService) AopContext.currentProxy()).save(u); // through the proxy
}
@Transactional
public void save(User u) { repository.save(u); }
}Exam tip: Self-invocation (
this.method()) on a@Transactionalmethod always bypasses the proxy. No transaction, no rollback. The fact that the method is annotated is irrelevant — the advice is only attached to the proxy reference.
Exam tip: The
private/final/ non-public method failures share a root cause with self-invocation: they all describe situations where the proxy cannot wrap the call. Memorise the four together — proxies cannot interceptprivate, non-public,final(CGLIB), or self-invoked methods.
readOnly — Hint, Not Enforcement
@Transactional(readOnly = true) is one of the most misread attributes on the exam. It does not make the database reject writes. It is a hint, propagated down the stack, that asks JPA and JDBC to behave more efficiently for read-only work. The database itself is never told to refuse INSERT or UPDATE.
Three things the hint actually does:
- Hibernate skips dirty checking. No flush happens at commit because Hibernate trusts the contract that nothing was modified. This is the largest measurable speedup on read-heavy methods that load many entities.
- The flush mode is set to
MANUAL. Auto-flush on query is disabled, so a query during the transaction will not synchronise the persistence context first. - Some drivers route the connection to a read replica. This is configuration-dependent; the JDBC driver / pool needs to support replica routing for it to kick in.
One thing it does not do:
- It does not block writes at the DB level. If your method runs an
UPDATEviaEntityManager.createNativeQuery(...), the database will execute it. Hibernate might log a warning, but no exception is thrown by default.
@Transactional(readOnly = true)
public List<Order> findRecentOrders() {
// Dirty checking is skipped. Flush mode is MANUAL.
// The DB will still accept writes if the code accidentally issues any.
return orderRepository.findTop100ByOrderByCreatedAtDesc();
}Exam tip:
readOnly = trueis a hint, not a constraint. It enables Hibernate optimisations and may route to a read replica, but the database itself does not enforce read-only semantics — a strayINSERTwill still commit.
TransactionTemplate — Programmatic Alternative
Declarative @Transactional covers most cases, but it has fixed boundaries: the entire method is the transaction. Three situations break that model — when you need a transaction boundary in the middle of a method, when propagation must be chosen at runtime, or when you want custom rollback logic without rethrowing exceptions. For those, Spring provides TransactionTemplate.
@Service
public class OrderService {
private final TransactionTemplate tx;
public OrderService(PlatformTransactionManager ptm) {
this.tx = new TransactionTemplate(ptm);
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
public void placeOrder(NewOrderRequest req) {
tx.execute(status -> {
repository.save(req.toOrder());
if (req.isSuspicious()) {
status.setRollbackOnly(); // commit() will roll back instead
}
return null;
});
}
}TransactionTemplate is thread-safe once configured; inject the PlatformTransactionManager Spring already provides, set the propagation / isolation / timeout you want, and reuse the template across calls. The lambda receives a TransactionStatus — the same handle the proxy gets — so you can mark rollback-only, query whether this is a new transaction, or roll back to a savepoint.
Two execute methods cover the common cases:
execute(TransactionCallback<T>)— returns a value from inside the transaction. Use when the transactional block produces a result.executeWithoutResult(Consumer<TransactionStatus>)— returns nothing. Cleaner when the body is avoidoperation; no awkwardreturn null;at the end.
Because the template invokes the callback directly — no proxy is involved — programmatic transactions sidestep all the self-invocation, private-method, and final-method gotchas. The cost is more boilerplate per call site and the discipline of remembering to use the template instead of writing the work inline.
Common Exam Traps Cheat Sheet
- Default rollback covers only
RuntimeExceptionandError. Checked exceptions commit. @Transactionalonprivate,protected, or package-private methods is silently ignored.@Transactionalon afinalmethod is ignored under CGLIB proxies.- Self-invocation (
this.method()) bypasses the proxy and the annotation. REQUIRES_NEWsuspends the caller and uses a separate connection — possible self-deadlock.NESTEDis not supported byJpaTransactionManager.DEFAULTisolation = database default, notREAD_COMMITTED.readOnly = trueis a hint, not a write barrier.- Catching an exception inside the method without rethrowing skips the rollback.
- Jakarta
@Transactionaland Spring@Transactionaluse different rollback keywords (rollbackOnvsrollbackFor). - Jakarta's
TxTypeenum has noNESTED— only Spring'sPropagationhas all seven values. - Class-level
@Transactionalis overridden by a method-level one. @Transactionalis processed by an AOP proxy registered during the bean lifecycle. AspectJ mode is the only configuration that lifts the proxy limitations.timeouton Spring's@Transactionalis in seconds, default-1(resource default).- Marking the current transaction rollback-only from inside the method:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
Frequently Asked Questions
Is @Transactional part of the 2V0-72.22 exam?
Yes. It is part of the Data Management section (~10-12% of all questions). Expect 2-4 questions covering propagation, rollback rules, isolation, or the self-invocation pitfall.
What is the default propagation in Spring?
REQUIRED. If a transaction already exists, the method joins it; if none exists, a new one is started. The exam often omits the propagation attribute on purpose to test whether you know the default.
Does @Transactional roll back on checked exceptions by default?
No. Spring rolls back on RuntimeException and Error only. Checked exceptions commit unless you set rollbackFor.
Why does my @Transactional method sometimes not start a transaction?
Most commonly: self-invocation (you called the method via this), the method is not public, the method is final under CGLIB proxies, or you imported the Jakarta annotation expecting Spring-specific attributes to apply.
What is the difference between REQUIRED and REQUIRES_NEW?
REQUIRED joins the existing transaction — one logical transaction, one connection. REQUIRES_NEW suspends the outer transaction and opens a new independent one on a separate connection. The inner can commit even if the outer rolls back.
Is readOnly = true enforced?
No. It is a performance hint. Hibernate skips dirty checking and some drivers route to a read replica, but the database will still accept INSERTs if the method runs them.
Next Steps
Start with the free Spring Professional practice questions, then review the broader 8-week study plan. For adjacent topics, read the Spring AOP and Pointcut Expressions guide — every transaction gotcha is rooted in proxy behaviour — and the Spring Data JPA exam guide for repository behaviour and JdbcTemplate basics.
→ Spring Professional practice questions — free questions with explanations
→ How to prepare — complete 8-week study guide — schedule and topic weights
→ Spring AOP and Pointcut Expressions guide — the proxy mechanism behind @Transactional
→ Spring Data JPA exam guide — repositories, queries, JdbcTemplate