Article 28's OrderService.place carried @Transactional, and article 26 used the annotation only to open a transaction around a demonstration. Neither said what the annotation buys, where it belongs, or when it actually rolls back. This article answers those three questions by breaking the order use case on purpose: first without a transaction, then with one, then with each exception rule and each mistake that makes @Transactional silently do nothing.
The examples use Spring Boot 4.1.1 and Java 21 with PostgreSQL 18 running in Docker, and the app runs on port 8130 instead of the default 8080. Every output comes from PostgreSQL except the test at the end of the TransactionTemplate section, which runs on H2. The logs use --logging.pattern.console=%logger{0}: %msg%n, which prints only the logger's short name and the message.
![]()
The first two sections show the problem and the log that makes transactions visible; the rest go through the rules one at a time.
What a transaction buys you: an order that fails halfway
The catalogue, the database and the logging
The project is article 28's: Order with its OrderLines, Product with its Category, Customer with its profile, and the OrderController behind POST /api/customers/{customerId}/orders. Two logger levels make the transactions and the SQL visible:
spring.application.name=demo
spring.jpa.open-in-view=false
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG spring.datasource.url=jdbc:postgresql://localhost:55430/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=createdocker run -d --name sb-a30-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55430:5432 postgres:18./gradlew -q bootJarjava -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8130 --spring.profiles.active=postgres '--logging.pattern.console=%logger{0}: %msg%n'ddl-auto=create recreates the tables on every start, and a startup runner seeds the same data each time: customer 1, an@example.com, and three products.
| id | SKU | Product | Stock |
|---|---|---|---|
| 1 | KB-01 | Mechanical keyboard | 10 |
| 2 | MS-01 | Wireless mouse | 1 |
| 3 | HUB-07 | USB-C hub | 5 |
The state of the database is read with psql inside the container after each request:
docker exec sb-a30-pg psql -U shop -d shop -c 'select id, sku, stock from products order by id' -c 'select (select count(*) from orders) as orders, (select count(*) from order_lines) as order_lines'Without @Transactional and without save: the stock is never written
The first run removes the annotation from article 28's place and changes nothing else:
@Transactional
public Order place(Long customerId, List<OrderItem> items) {
Customer customer = customers.findById(customerId)
.orElseThrow(() -> new CustomerNotFoundException(customerId));
Order order = new Order(customer);
for (OrderItem item : items) {
Product product = productService.reserveStock(item.productId(), item.quantity());
order.addLine(new OrderLine(product, item.quantity()));
}
return orders.save(order);
}ProductService.reserveStock is still article 28's: it lowers the stock on the entity and returns it. An order that should succeed, two keyboards and one hub:
curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":3,"quantity":1}]}' http://localhost:8130/api/customers/1/ordersHTTP/1.1 201
Location: http://localhost:8130/api/orders/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:34:44 GMT
{"id":1,"customerId":1,"lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80},{"productId":3,"quantity":1,"unitPrice":39.00,"lineTotal":39.00}],"total":218.80} id | sku | stock
----+--------+-------
1 | KB-01 | 10
2 | MS-01 | 1
3 | HUB-07 | 5
(3 rows)
orders | order_lines
--------+-------------
1 | 2
(1 row)A 201, an order with two lines, and not one unit of stock taken. The log, trimmed to the lines that start and commit a transaction and to the SQL, explains why:
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
SQL: insert into orders (customer_id) values (?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
JpaTransactionManager: Initiating transaction commitFour repository calls, four transactions, each opened and committed by the repository itself. When findById returned a product, its transaction and persistence context were already closed, so setStock changed a detached entity that nothing watched: dirty checking only works inside a transaction, as article 26 showed. The order and its lines were written only because orders.save persisted them.
Saving every change: a partial write
Code written without a transaction has to save each change itself, as article 21's in-memory reserveStock did:
public Product reserveStock(Long id, int quantity) {
Product product = findById(id);
if (product.getStock() < quantity) {
throw new InsufficientStockException(product.getSku(), product.getStock(), quantity);
}
product.setStock(product.getStock() - quantity);
return product;
return products.save(product);
}Now the order this article uses from here on: two keyboards and three mice, while only one mouse is in stock.
curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":2,"quantity":3}]}' http://localhost:8130/api/customers/1/ordersHTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:35:53 GMT
{"detail":"Only 1 of MS-01 in stock, 3 requested","instance":"/api/customers/1/orders","status":409,"title":"Conflict"} id | sku | stock
----+--------+-------
1 | KB-01 | 8
2 | MS-01 | 1
3 | HUB-07 | 5
(3 rows)
orders | order_lines
--------+-------------
0 | 0
(1 row)The client was told the order failed, and two keyboards are gone from the stock for an order that does not exist. The log, trimmed the same way:
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commitsave merged the detached keyboard in a transaction of its own and committed the UPDATE before the mouse was even loaded. When InsufficientStockException came out of the second reserveStock, there was nothing left to undo.
The same order with @Transactional
@Transactional
public Order place(Long customerId, List<OrderItem> items) {The same failing request returned the same 409 body, and this time:
id | sku | stock
----+--------+-------
1 | KB-01 | 10
2 | MS-01 | 1
3 | HUB-07 | 5
(3 rows)
orders | order_lines
--------+-------------
0 | 0
(1 row)Nothing was left behind. The successful order from the first run, two keyboards and one hub, then answered HTTP/1.1 201 with Location: http://localhost:8130/api/orders/1 and the same body as before, and wrote all of it:
id | sku | stock
----+--------+-------
1 | KB-01 | 8
2 | MS-01 | 1
3 | HUB-07 | 4
(3 rows)
orders | order_lines
--------+-------------
1 | 2
(1 row)That is what a transaction buys: the statements of one use case take effect together or not at all. Inside the transaction, the save added to reserveStock sends no SQL, because the product is still managed and dirty checking writes it at commit, as the next section's log shows. It stays in the code for the rest of the article.
Seeing transactions in the log
The transaction manager Spring Boot configures for JPA
The demonstrations that need no HTTP request ran from a startup runner, TransactionLab, in a lab package behind a lab profile. It receives the beans it inspects through its constructor, and prints the stock with a JdbcClient query outside any transaction, which leaves no line in the JpaTransactionManager log:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=postgres,lab --spring.main.web-application-type=none '--logging.pattern.console=%logger{0}: %msg%n' @Override
public void run(ApplicationArguments args) throws Exception {
context.getBeansOfType(PlatformTransactionManager.class)
.forEach((name, bean) -> log.info("PlatformTransactionManager bean: {} -> {}", name, bean.getClass().getName()));
log.info("transactionManager defined in: {}", context.getBeanFactory().getBeanDefinition("transactionManager").getResourceDescription());
log.info("TransactionTemplate beans: {}", Arrays.toString(context.getBeanNamesForType(TransactionTemplate.class)));
log.info("OrderService bean class: {}", orderService.getClass().getName());
log.info("its superclass: {}", orderService.getClass().getSuperclass().getName());
log.info("AopUtils.isCglibProxy: {}", AopUtils.isCglibProxy(orderService));
// the demonstrations of the later sections follow
}
private List<String> stocks() {
return jdbcClient.sql("select sku, stock from products order by id")
.query((rs, n) -> rs.getString(1) + "=" + rs.getInt(2))
.list();
}TransactionLab: PlatformTransactionManager bean: transactionManager -> org.springframework.orm.jpa.JpaTransactionManager
TransactionLab: transactionManager defined in: class path resource [org/springframework/boot/hibernate/autoconfigure/HibernateJpaConfiguration.class]
TransactionLab: TransactionTemplate beans: [transactionTemplate]There is exactly one transaction manager, transactionManager, of type JpaTransactionManager, defined by HibernateJpaConfiguration from Boot's spring-boot-hibernate module. Article 25, with only the JDBC starter, got a JdbcTransactionManager; with the JPA starter that one is not created. JpaTransactionManager also shares its transaction with plain JDBC code on the same DataSource, which the readOnly section confirms with a JdbcClient query. Boot also registered a TransactionTemplate named transactionTemplate, used near the end of the article.
Begin, commit and rollback in the JpaTransactionManager log
@Transactional does nothing that is not logged by org.springframework.orm.jpa.JpaTransactionManager at DEBUG. The complete log of the failing order with @Transactional on place:
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@282a596e]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1757141441<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(1757141441<open>)]
JpaTransactionManager: Closing JPA EntityManager after transactionThe end of the successful order's log:
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1945821582<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: insert into orders (customer_id) values (?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1945821582<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction| Log line | What happened |
|---|---|
Creating new transaction with name [...OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT | the proxy of OrderService began a transaction named after the method, with its settings |
Opened new EntityManager [SessionImpl(...)] for JPA transaction | one EntityManager, one persistence context, for the whole method |
Exposing JPA transaction as JDBC [...] | the same connection is available to JDBC code |
Found thread-bound EntityManager then Participating in existing transaction | a repository call joined the running transaction instead of opening its own; one pair per call, four here |
Initiating transaction commit, Committing JPA transaction | the method returned; Hibernate flushed the pending UPDATEs during the commit |
Initiating transaction rollback, Rolling back JPA transaction | the method threw; nothing was flushed |
Closing JPA EntityManager after transaction | the persistence context ended with the transaction |
The INSERTs appear before the commit because IDENTITY ids need the row at save time; the UPDATEs appear after Committing, when dirty checking found the two changed products. In the failing run no UPDATE was sent at all.
How @Transactional works: the bean you inject is a proxy
The next three lines of TransactionLab print what the controller actually received when it was given an OrderService:
TransactionLab: OrderService bean class: com.example.demo.order.OrderService$$SpringCGLIB$$0
TransactionLab: its superclass: com.example.demo.order.OrderService
TransactionLab: AopUtils.isCglibProxy: trueThe bean is not your OrderService but a subclass generated at startup, OrderService$$SpringCGLIB$$0, which wraps the real object. Every call from another bean goes through it, and for a method with @Transactional it runs the sequence the log showed:
OrderControllercallsservice.place(customerId, items)on the proxy.- The proxy reads the
@Transactionalattributes ofplaceand asksJpaTransactionManagerto begin:Creating new transaction. - The proxy calls the real
place. Repository calls inside it find the transaction bound to the thread and join it. - If
placereturns, the proxy commits and returns theOrderto the controller. If it throws an exception that the rollback rules select, the proxy rolls back and rethrows the same exception, which the advice turns into a 409.

Everything that trips people up with @Transactional follows from this picture: the transaction exists only for calls that pass through the proxy, and the decision to commit is made by the proxy when the method ends. How Spring builds proxies, and the difference between CGLIB and JDK proxies, belongs to the Advanced course.
Where to put @Transactional
On the service method that is the use case
Article 21 put the stock rule in OrderService.place because placing an order is one business operation, whoever triggers it. The transaction belongs to the same method, for the same reason.
- Not on the repository. A repository method is one step of a use case. The partial write above is exactly what one transaction per repository call produces.
- Not on the controller. The order would only be atomic when it arrives over HTTP. A scheduled job, a message listener or another service calling
OrderService.placedirectly would run it without a transaction, and the controller would mix HTTP concerns into the unit of work. - On the public service method that performs one use case, so that every caller, web or not, gets the same all-or-nothing behaviour.
What Spring Data repositories already do
The repository calls in the first run each had a transaction without any annotation in the project. They come from SimpleJpaRepository, the class behind every JpaRepository since article 26. Its annotations, read from the jar with javap:
javap -v -cp spring-data-jpa-4.1.1.jar org.springframework.data.jpa.repository.support.SimpleJpaRepositoryWhere in SimpleJpaRepository 4.1.1 | Annotation |
|---|---|
| the class | @Transactional(readOnly = true) |
save, saveAll, saveAndFlush, saveAllAndFlush | @Transactional |
delete, deleteById, deleteAll, deleteAllById, deleteAllInBatch, deleteAllByIdInBatch, flush | @Transactional |
delete and update taking a DeleteSpecification or UpdateSpecification | @Transactional |
findById, findAll, count, existsById and the other reads | none of their own, so the class-level readOnly = true applies |
The log agreed: findById ran with PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly and save with PROPAGATION_REQUIRED,ISOLATION_DEFAULT. PROPAGATION_REQUIRED means "join a running transaction, or start one". A class in the lab profile makes the same three repository calls with and without an annotation of its own:
package com.example.demo.lab;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Profile("lab")
public class RepositoryCalls {
private static final Logger log = LoggerFactory.getLogger(RepositoryCalls.class);
private final ProductRepository products;
RepositoryCalls(ProductRepository products) {
this.products = products;
}
public void readWithoutTransaction() {
Product keyboard = products.findById(1L).orElseThrow();
Product mouse = products.findById(2L).orElseThrow();
long count = products.count();
log.info("{}, {}, {} products", keyboard.getSku(), mouse.getSku(), count);
}
@Transactional
public void readInOneTransaction() {
Product keyboard = products.findById(1L).orElseThrow();
Product mouse = products.findById(2L).orElseThrow();
long count = products.count();
log.info("{}, {}, {} products", keyboard.getSku(), mouse.getSku(), count);
}
}readWithoutTransaction():
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(38669275<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@1495f70]
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(38669275<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(952876299<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@56a34c0e]
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(952876299<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.count]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(640592192<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@6aa152b7]
SQL: select count(*) from products p1_0
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(640592192<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
RepositoryCalls: KB-01, MS-01, 3 productsreadInOneTransaction():
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.RepositoryCalls.readInOneTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(319123198<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@121ec5c2]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(319123198<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(319123198<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(319123198<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select count(*) from products p1_0
RepositoryCalls: KB-01, MS-01, 3 products
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(319123198<open>)]
JpaTransactionManager: Closing JPA EntityManager after transactionThree transactions and three EntityManagers without the annotation, one of each with it. The repository's own @Transactional is a fallback for calls made outside a transaction; inside a service transaction it simply participates.
Class level vs method level
SimpleJpaRepository puts readOnly = true on the class and @Transactional on the methods that write. A service can do the same:
@Service
@Transactional(readOnly = true)
public class OrderService {
// fields and constructor unchanged
@Transactional
public Order place(Long customerId, List<OrderItem> items) {
// unchanged
}
@Transactional(readOnly = true)
public Order findById(Long id) {
return orders.findWithLinesById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
@Transactional(readOnly = true)
public List<Order> findAll() {
return orders.findAllWithLines();
}
}A GET /api/orders/1, answered with 200, then a POST of three mice, answered with 409, trimmed to the lines that start and end the transactions:
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Initiating transaction rollbackfindById inherited readOnly from the class. place did not: an annotation on the method replaces the class-level one as a whole, its attributes are not merged with it. The class-level annotation also covers public methods added later, which is the reason to prefer it for a service that is mostly reads.
When does @Transactional roll back?
The proxy decides at the moment the method ends, from what came out of it. Each branch of this picture is one of the runs below.

An unchecked exception rolls back
InsufficientStockException has extended RuntimeException since article 21, and the log of the failing order already showed the result: Initiating transaction rollback, no UPDATE, and the stock unchanged in psql. The same holds for any Error.
A checked exception commits
Making the exception checked takes a throws clause everywhere it passes:
public class InsufficientStockException extends RuntimeException {
public class InsufficientStockException extends Exception { public Product reserveStock(Long id, int quantity) {
public Product reserveStock(Long id, int quantity) throws InsufficientStockException { @Transactional
public Order place(Long customerId, List<OrderItem> items) {
public Order place(Long customerId, List<OrderItem> items) throws InsufficientStockException { @PostMapping("/api/customers/{customerId}/orders")
public ResponseEntity<OrderResponse> place(@PathVariable Long customerId,
@Valid @RequestBody PlaceOrderRequest request) {
@Valid @RequestBody PlaceOrderRequest request) throws InsufficientStockException { @ExceptionHandler(InsufficientStockException.class)
public ProblemDetail conflict(RuntimeException e) {
public ProblemDetail conflict(Exception e) { OrderController also imports com.example.demo.product.InsufficientStockException. The failing order returned the same 409 body as before. The end of its log:
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(43570430<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction id | sku | stock
----+--------+-------
1 | KB-01 | 8
2 | MS-01 | 1
3 | HUB-07 | 5
(3 rows)
orders | order_lines
--------+-------------
0 | 0
(1 row)The partial write is back, inside a transaction. The exception still left place and still became a 409, but by default Spring rolls back only for RuntimeException and Error; a checked exception is treated as an outcome the method declared, so the proxy committed the keyboard's new stock.
rollbackFor and noRollbackFor
When a checked exception must roll back, name it:
@Transactional
@Transactional(rollbackFor = InsufficientStockException.class)
public Order place(Long customerId, List<OrderItem> items) throws InsufficientStockException {The same request, trimmed to the lines that start and end the transaction:
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-com.example.demo.product.InsufficientStockException
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(750252235<open>)]
JpaTransactionManager: Closing JPA EntityManager after transactionpsql showed 10, 1 and 5 again, with no order. The rule is part of the transaction's definition: - before an exception class means "roll back for it". noRollbackFor is the opposite rule and is logged with +. It fits a method that must keep its work even though it reports a problem, such as reserving whatever stock is left and then signalling the shortfall:
@Transactional(noRollbackFor = InsufficientStockException.class)
public void reserveUpTo(Long productId, int quantity) {
Product product = products.findById(productId).orElseThrow();
int reserved = Math.min(product.getStock(), quantity);
product.setStock(product.getStock() - reserved);
if (reserved < quantity) {
throw new InsufficientStockException(product.getSku(), reserved, quantity);
}
}TransactionLab called rollbackRulesLab.reserveUpTo(2L, 3), in a run where InsufficientStockException was still unchecked:
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.RollbackRulesLab.reserveUpTo]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,+com.example.demo.product.InsufficientStockException
JpaTransactionManager: Opened new EntityManager [SessionImpl(1244881865<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@63cf6497]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1244881865<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1244881865<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: threw com.example.demo.product.InsufficientStockException: Only 1 of MS-01 in stock, 3 requested
TransactionLab: stocks after: [KB-01=12, MS-01=0, HUB-07=9]A RuntimeException left the method and the transaction committed: MS-01 went from 1 to 0. KB-01 and HUB-07 had been changed by earlier demonstrations in the same run. The rest of the article uses the unchecked InsufficientStockException again, with the throws clauses and the handler parameter reverted:
public class InsufficientStockException extends Exception {
public class InsufficientStockException extends RuntimeException { place goes back to a plain @Transactional.
An exception caught inside the method commits
Some orders should go through with whatever is available. A second use case catches the exception per line and skips that line:
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
// ...
@Transactional
public Order placeAvailable(Long customerId, List<OrderItem> items) {
Customer customer = customers.findById(customerId)
.orElseThrow(() -> new CustomerNotFoundException(customerId));
Order order = new Order(customer);
for (OrderItem item : items) {
try {
Product product = productService.reserveStock(item.productId(), item.quantity());
order.addLine(new OrderLine(product, item.quantity()));
} catch (InsufficientStockException e) {
log.info("Skipping a line: {}", e.getMessage());
}
}
return orders.save(order);
} @PostMapping("/api/customers/{customerId}/orders")
public ResponseEntity<OrderResponse> place(@PathVariable Long customerId,
@RequestParam(defaultValue = "false") boolean skipUnavailable,
@Valid @RequestBody PlaceOrderRequest request) {
List<OrderItem> items = request.lines().stream()
.map(line -> new OrderItem(line.productId(), line.quantity()))
.toList();
Order order = service.place(customerId, items);
Order order = skipUnavailable
? service.placeAvailable(customerId, items)
: service.place(customerId, items); curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":2,"quantity":3}]}' 'http://localhost:8130/api/customers/1/orders?skipUnavailable=true'HTTP/1.1 201
Location: http://localhost:8130/api/orders/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 13 Sep 2026 10:45:42 GMT
{"id":1,"customerId":1,"lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80}],"total":179.80}JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.placeAvailable]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@101954ff]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
OrderService: Skipping a line: Only 1 of MS-01 in stock, 3 requested
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(306657394<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: insert into orders (customer_id) values (?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(306657394<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction id | sku | stock
----+--------+-------
1 | KB-01 | 8
2 | MS-01 | 1
3 | HUB-07 | 5
(3 rows)
orders | order_lines
--------+-------------
1 | 1
(1 row)The exception never left placeAvailable, so the proxy saw a normal return and committed the order with its one line. Note what reserveStock looks like here: it has no @Transactional of its own, so its exception reached the catch without passing through any transactional proxy on the way. The rollback-only trap section changes exactly that.
What readOnly = true changes
A changed entity is not written
@Transactional(readOnly = true)
public void changeStock(Long productId) {
Product product = products.findById(productId).orElseThrow();
product.setStock(product.getStock() + 100);
log.info("stock of {} set to {} on a managed entity", product.getSku(), product.getStock());
}TransactionLab called readOnlyLab.changeStock(1L):
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.ReadOnlyLab.changeStock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(291728707<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@753b6846]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(291728707<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
ReadOnlyLab: stock of KB-01 set to 110 on a managed entity
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(291728707<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: stocks after: [KB-01=10, MS-01=1, HUB-07=5]The entity was managed and changed, the transaction committed, and no UPDATE was sent: in a read-only transaction Hibernate 7.4 does not flush the changes dirty checking would otherwise have found. There is no exception and no warning; the change is simply lost. That is harmless in a query method and a silent bug in a method that is supposed to write, which is how a class-level readOnly = true bites when a writing method forgets its own @Transactional.
PostgreSQL rejects a write in a read-only transaction
A write that reaches the database behaves differently. IDENTITY ids make save send its INSERT at once, flush or not:
@Transactional(readOnly = true)
public void saveNewProduct() {
String readOnly = jdbcClient.sql("select current_setting('transaction_read_only')").query(String.class).single();
log.info("transaction_read_only = {}", readOnly);
Category keyboards = categories.findById(1L).orElseThrow();
products.save(new Product("Compact keyboard", "KB-02", new BigDecimal("59.00"), 5, keyboards));
}JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.ReadOnlyLab.saveNewProduct]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(107240479<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@237e4ed9]
ReadOnlyLab: transaction_read_only = on
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(107240479<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select c1_0.id,c1_0.name from categories c1_0 where c1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(107240479<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: insert into products (category_id,name,price,sku,stock) values (?,?,?,?,?)
error: HHH000247: ErrorCode: 0, SQLState: 25006
error: ERROR: cannot execute INSERT in a read-only transaction
JpaTransactionManager: Participating transaction failed - marking existing transaction as rollback-only
JpaTransactionManager: Setting JPA transaction on EntityManager [SessionImpl(107240479<open>)] rollback-only
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(107240479<open>)]
JpaTransactionManager: Closing JPA EntityManager after transactionThe exception chain TransactionLab printed:
TransactionLab: org.springframework.orm.jpa.JpaSystemException: could not execute statement [ERROR: cannot execute INSERT in a read-only transaction] [insert into products (category_id,name,price,sku,stock) values (?,?,?,?,?)]
TransactionLab: org.hibernate.exception.GenericJDBCException: could not execute statement [ERROR: cannot execute INSERT in a read-only transaction] [insert into products (category_id,name,price,sku,stock) values (?,?,?,?,?)]
TransactionLab: org.postgresql.util.PSQLException: ERROR: cannot execute INSERT in a read-only transaction- The connection itself was read-only.
current_setting('transaction_read_only')returnedon, read throughJdbcClientinside the JPA transaction, so PostgreSQL knew about the flag, not only Hibernate. savedid not get its own read-write transaction. Its@TransactionalwithoutreadOnlyjoined the running read-only transaction, and the INSERT failed with SQLState25006.- The exception is generic. It arrives as
JpaSystemException, not as one of the specificDataAccessExceptiontypes, so a handler written forDataIntegrityViolationExceptiondoes not catch it.
readOnly = true belongs on query methods, or on the class of a service that is mostly reads with @Transactional on each method that writes. It stops dirty checking from flushing, and on PostgreSQL it makes the database refuse writes.
Self-invocation: calling a @Transactional method from the same bean
A restock job adds stock to several products. restock is transactional; restockAll loops over it:
package com.example.demo.lab;
import java.util.List;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Component
@Profile("lab")
public class Restocker {
private static final Logger log = LoggerFactory.getLogger(Restocker.class);
private final ProductRepository products;
Restocker(ProductRepository products) {
this.products = products;
}
public void restockAll(List<Long> productIds, int quantity) {
for (Long productId : productIds) {
restock(productId, quantity);
}
}
@Transactional
public void restockAllInOneTransaction(List<Long> productIds, int quantity) {
for (Long productId : productIds) {
restock(productId, quantity);
}
}
@Transactional
public void restock(Long productId, int quantity) {
log.info("restock({}, {}): transaction active = {}", productId, quantity,
TransactionSynchronizationManager.isActualTransactionActive());
Product product = products.findById(productId).orElseThrow();
product.setStock(product.getStock() + quantity);
}
}TransactionSynchronizationManager.isActualTransactionActive() answers whether a transaction is running on the current thread. restock relies on dirty checking, so without a transaction its change is not written. First, TransactionLab calls restocker.restock(3L, 1) directly:
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.Restocker.restock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(476119323<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@75af5f2a]
Restocker: restock(3, 1): transaction active = true
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(476119323<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(476119323<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: stocks after: [KB-01=10, MS-01=1, HUB-07=6]Then restocker.restockAll(List.of(1L, 3L), 1):
Restocker: restock(1, 1): transaction active = false
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(1007392323<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@746f1505]
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1007392323<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
Restocker: restock(3, 1): transaction active = false
JpaTransactionManager: Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
JpaTransactionManager: Opened new EntityManager [SessionImpl(1089993541<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@4c3c1963]
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1089993541<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: stocks after: [KB-01=10, MS-01=1, HUB-07=6]The same @Transactional method ran twice with transaction active = false. No Creating new transaction with name [...Restocker.restock] appeared, only the repository's own read-only transactions for findById, no UPDATE was sent, and neither product gained any stock. restockAll has no annotation, so the proxy passed the call straight to the real object, and inside the real object restock(productId, quantity) means this.restock(...): a plain Java call on the target, which never goes back out through the proxy.
![Two panels. Left: TransactionLab calls restocker.restock(3L, 1), the call crosses the proxy border where a transaction begins, the log shows Creating new transaction with name [com.example.demo.lab.Restocker.restock], transaction active = true and an update, and HUB-07 goes from 5 to 6. Right: TransactionLab calls restocker.restockAll(List.of(1L, 3L), 1), which passes through the proxy without a transaction, then this.restock() stays inside the target object, the log shows transaction active = false and only SimpleJpaRepository.findById transactions, and no stock changes](/images/blog/sb-self-invocation-bypass.en.webp)
Two ways to fix self-invocation
Annotate the method that is called from outside. restockAllInOneTransaction is restockAll with @Transactional, and the inner this.restock calls then run inside its transaction. Trimmed to the transaction lines, the flags and the UPDATEs:
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.Restocker.restockAllInOneTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Restocker: restock(1, 1): transaction active = true
Restocker: restock(3, 1): transaction active = true
JpaTransactionManager: Initiating transaction commit
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
TransactionLab: stocks after: [KB-01=12, MS-01=1, HUB-07=8]Move the loop to another bean, so every call to restock crosses the proxy:
package com.example.demo.lab;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("lab")
public class RestockBatch {
private final Restocker restocker;
RestockBatch(Restocker restocker) {
this.restocker = restocker;
}
public void restockAll(List<Long> productIds, int quantity) {
for (Long productId : productIds) {
restocker.restock(productId, quantity);
}
}
}JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.Restocker.restock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Restocker: restock(1, 1): transaction active = true
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.Restocker.restock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Restocker: restock(3, 1): transaction active = true
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
TransactionLab: stocks after: [KB-01=11, MS-01=1, HUB-07=7]This run came before restockAllInOneTransaction, which is why the numbers are one lower. The two fixes differ in scope. The first gives the whole batch one transaction, so one failure rolls back every product; the second gives each product its own, so earlier products stay committed when a later one fails. Choose by what the batch must guarantee. Other workarounds, such as injecting the bean into itself, are for the Advanced course.
@Transactional on private, package-private and protected methods
package com.example.demo.lab;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Component
@Profile("lab")
public class VisibilityLab {
private static final Logger log = LoggerFactory.getLogger(VisibilityLab.class);
@Transactional
public void publicMethod() {
report("public");
}
@Transactional
protected void protectedMethod() {
report("protected");
}
@Transactional
void packagePrivateMethod() {
report("package-private");
}
@Transactional
private void privateMethod() {
report("private");
}
private static void report(String visibility) {
log.info("{} method: transaction active = {}", visibility,
TransactionSynchronizationManager.isActualTransactionActive());
}
}TransactionLab, in the same package, lists which methods the proxy class declares, then calls each one. A private method cannot be called from another class, so it is invoked by reflection on the proxy object:
log.info("bean class: {}", visibilityLab.getClass().getName());
for (Method m : VisibilityLab.class.getDeclaredMethods()) {
if (!m.getName().endsWith("Method")) continue;
boolean overridden = Arrays.stream(visibilityLab.getClass().getDeclaredMethods())
.anyMatch(p -> p.getName().equals(m.getName()));
log.info("{} {}: overridden by the proxy class = {}", Modifier.toString(m.getModifiers()), m.getName(), overridden);
}
visibilityLab.publicMethod();
visibilityLab.protectedMethod();
visibilityLab.packagePrivateMethod();
Method privateMethod = VisibilityLab.class.getDeclaredMethod("privateMethod");
privateMethod.setAccessible(true);
try {
privateMethod.invoke(visibilityLab);
} catch (InvocationTargetException e) {
chain(e.getCause());
}Trimmed to the listing, the transaction starts and the flags:
TransactionLab: bean class: com.example.demo.lab.VisibilityLab$$SpringCGLIB$$0
TransactionLab: protected protectedMethod: overridden by the proxy class = true
TransactionLab: packagePrivateMethod: overridden by the proxy class = true
TransactionLab: public publicMethod: overridden by the proxy class = true
TransactionLab: private privateMethod: overridden by the proxy class = false
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.VisibilityLab.publicMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
VisibilityLab: public method: transaction active = true
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.VisibilityLab.protectedMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
VisibilityLab: protected method: transaction active = true
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.VisibilityLab.packagePrivateMethod]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
VisibilityLab: package-private method: transaction active = true
VisibilityLab: private method: transaction active = falseOn Spring Framework 7.0.9 the rule "only public methods are transactional" no longer holds for a class-based proxy. The generated subclass overrode the protected and the package-private method, and both calls began a transaction. The switch still exists in spring-tx: AbstractFallbackTransactionAttributeSource returns no transaction attribute for a non-public method when allowPublicMethodsOnly() is true.
javap -c -p -cp spring-tx-7.0.9.jar org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource protected org.springframework.transaction.interceptor.TransactionAttribute computeTransactionAttribute(java.lang.reflect.Method, java.lang.Class<?>);
Code:
0: aload_0
1: invokevirtual #111 // Method allowPublicMethodsOnly:()Z
4: ifeq 19
7: aload_1
8: invokevirtual #114 // Method java/lang/reflect/Method.getModifiers:()I
11: invokestatic #120 // Method java/lang/reflect/Modifier.isPublic:(I)Z
14: ifne 19
17: aconst_null
18: areturnIn this Boot 4.1.1 application the check let protected and package-private methods through. A private method is different: a subclass cannot override it, so the proxy has nothing to intercept, and even invoked on the proxy object it ran without a transaction. Since a private method can only be called from inside its own class, it is self-invocation anyway. Keep @Transactional on the public methods that form the service's API; the other visibilities work here, but they are rarely called from another bean.
The rollback-only trap and UnexpectedRollbackException
ProductService.reserveStock is a use case of its own, so, following the advice above, it gets @Transactional too:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductService {
// ...
@Transactional
public Product reserveStock(Long id, int quantity) {Nothing else changed. The request that returned 201 with one line in the previous section:
curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":2,"quantity":3}]}' 'http://localhost:8130/api/customers/1/orders?skipUnavailable=true'HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 02:45:35 GMT
Connection: close
{"timestamp":"2026-09-14T02:45:35.323Z","status":500,"error":"Internal Server Error","path":"/api/customers/1/orders"}The log, with the stack frames removed:
JpaTransactionManager: Creating new transaction with name [com.example.demo.order.OrderService.placeAvailable]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5b68556]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select c1_0.id,c1_0.email from customers c1_0 where c1_0.id=?
SQL: select cp1_0.id,cp1_0.customer_id,cp1_0.full_name,cp1_0.phone from customer_profiles cp1_0 where cp1_0.customer_id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Participating transaction failed - marking existing transaction as rollback-only
JpaTransactionManager: Setting JPA transaction on EntityManager [SessionImpl(1114214325<open>)] rollback-only
OrderService: Skipping a line: Only 1 of MS-01 in stock, 3 requested
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1114214325<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: insert into orders (customer_id) values (?)
SQL: insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1114214325<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
[dispatcherServlet]: Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only] with root cause
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only id | sku | stock
----+--------+-------
1 | KB-01 | 10
2 | MS-01 | 1
3 | HUB-07 | 5
(3 rows)
orders | order_lines
--------+-------------
0 | 0
(1 row)The two INSERTs were sent and rolled back, the keyboard's stock was never flushed, and the client got a 500 from Spring Boot's default error handling, because no @ExceptionHandler covers UnexpectedRollbackException.

The difference from the previous section is one annotation. reserveStock now has a proxy of its own, and that proxy also sits between the exception and the catch. When InsufficientStockException left reserveStock, its proxy applied the rollback rules: a RuntimeException, so roll back. It could not roll back a transaction it had only joined, so it marked the shared transaction rollback-only (Participating transaction failed - marking existing transaction as rollback-only). placeAvailable caught the exception and returned normally, its proxy asked for a commit, and the transaction manager rolled back instead and threw UnexpectedRollbackException so that the caller does not believe the order was saved.
Catching an exception from save()
A repository method is a transactional proxy too. Catching a constraint violation from save inside a service transaction:
@Transactional
public void saveDuplicateSkuAndCatch() {
Category keyboards = categories.findById(1L).orElseThrow();
try {
products.save(new Product("Keyboard copy", "KB-01", new BigDecimal("79.00"), 1, keyboards));
} catch (DataIntegrityViolationException e) {
log.info("caught {}", e.getClass().getName());
}
}The log from the INSERT on:
SQL: insert into products (category_id,name,price,sku,stock) values (?,?,?,?,?)
error: HHH000247: ErrorCode: 0, SQLState: 23505
error: ERROR: duplicate key value violates unique constraint "products_sku_key"
Detail: Key (sku)=(KB-01) already exists.
JpaTransactionManager: Participating transaction failed - marking existing transaction as rollback-only
JpaTransactionManager: Setting JPA transaction on EntityManager [SessionImpl(483248328<open>)] rollback-only
RollbackRulesLab: caught org.springframework.dao.DataIntegrityViolationException
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(483248328<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: threw org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-onlySame trap, same message. SimpleJpaRepository.save is @Transactional, so its proxy marked the transaction before the catch block ran.
How to avoid the trap
- Do not use an exception from another transactional method as a normal outcome. In the previous section
reserveStockhad no proxy between the exception and thecatch, and the order committed with one line. When the method must stay@Transactional, check first (is there enough stock, does the SKU already exist, the question article 27'sexistsBySkuanswers) and call it only when it will succeed. - Or let the exception propagate.
placedoes that: the whole order rolls back and the client gets a 409 it can understand. - When a part must commit or roll back on its own, that part needs its own transaction, which is what
@Transactional(propagation = Propagation.REQUIRES_NEW)is for; propagation is covered in the Advanced course.
jakarta.transaction.Transactional vs Spring's @Transactional
Two annotations are called @Transactional, and an IDE offers both. The Jakarta one is already on the classpath, because Hibernate depends on it:
./gradlew dependencies --configuration compileClasspath| | | +--- org.hibernate.orm:hibernate-core:7.4.5.Final
| | | | +--- org.hibernate.orm:hibernate-platform:7.4.5.Final
| | | | | +--- org.hibernate.orm:hibernate-core:7.4.5.Final (c)
| | | | | +--- jakarta.persistence:jakarta.persistence-api:3.2.0 (c)
| | | | | \--- jakarta.transaction:jakarta.transaction-api:2.0.1 (c)
| | | | +--- jakarta.persistence:jakarta.persistence-api:3.2.0
| | | | \--- jakarta.transaction:jakarta.transaction-api:2.0.1Their attributes, read with javap:
javap -cp spring-tx-7.0.9.jar org.springframework.transaction.annotation.TransactionalCompiled from "Transactional.java"
public interface org.springframework.transaction.annotation.Transactional extends java.lang.annotation.Annotation {
public abstract java.lang.String value();
public abstract java.lang.String transactionManager();
public abstract java.lang.String[] label();
public abstract org.springframework.transaction.annotation.Propagation propagation();
public abstract org.springframework.transaction.annotation.Isolation isolation();
public abstract int timeout();
public abstract java.lang.String timeoutString();
public abstract boolean readOnly();
public abstract java.lang.Class<? extends java.lang.Throwable>[] rollbackFor();
public abstract java.lang.String[] rollbackForClassName();
public abstract java.lang.Class<? extends java.lang.Throwable>[] noRollbackFor();
public abstract java.lang.String[] noRollbackForClassName();
}javap -cp jakarta.transaction-api-2.0.1.jar jakarta.transaction.TransactionalCompiled from "Transactional.java"
public interface jakarta.transaction.Transactional extends java.lang.annotation.Annotation {
public abstract jakarta.transaction.Transactional$TxType value();
public abstract java.lang.Class[] rollbackOn();
public abstract java.lang.Class[] dontRollbackOn();
}Spring honours both. A bean using the Jakarta one, with a checked exception:
package com.example.demo.lab;
import java.io.IOException;
import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;
import jakarta.transaction.Transactional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("lab")
public class JakartaLab {
private final ProductRepository products;
JakartaLab(ProductRepository products) {
this.products = products;
}
@Transactional
public void importStock(Long productId) throws IOException {
Product product = products.findById(productId).orElseThrow();
product.setStock(product.getStock() + 1);
throw new IOException("stock file truncated");
}
@Transactional(rollbackOn = IOException.class)
public void importStockRollbackOn(Long productId) throws IOException {
Product product = products.findById(productId).orElseThrow();
product.setStock(product.getStock() + 1);
throw new IOException("stock file truncated");
}
}Both calls from TransactionLab, trimmed to the transaction lines, the SQL that wrote and the results:
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.JakartaLab.importStock]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Initiating transaction commit
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
TransactionLab: threw java.io.IOException: stock file truncated
TransactionLab: stocks after: [KB-01=12, MS-01=1, HUB-07=9]
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.JakartaLab.importStockRollbackOn]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-java.io.IOException
JpaTransactionManager: Initiating transaction rollback
TransactionLab: threw java.io.IOException: stock file truncated
TransactionLab: stocks after: [KB-01=12, MS-01=1, HUB-07=9]The Jakarta annotation started a JpaTransactionManager transaction like Spring's, committed on a checked exception like Spring's, and its rollbackOn became the same -java.io.IOException rule that rollbackFor produces. HUB-07 went from 8 to 9 once, by the method without the rule.
Spring @Transactional | jakarta.transaction.Transactional | |
|---|---|---|
| Roll back for these exceptions | rollbackFor, rollbackForClassName | rollbackOn |
| Do not roll back for these | noRollbackFor, noRollbackForClassName | dontRollbackOn |
| Propagation | propagation | value, a Transactional.TxType |
readOnly, timeout, isolation, transactionManager, label | yes | no such attributes |
| Checked exception without a rule, in these runs | commit | commit |
Use Spring's. The Jakarta annotation has no readOnly, so a service with a class-level read-only default cannot be expressed with it, and mixing the two in one code base makes readers check an import to know which attribute names apply.
TransactionTemplate for the rare programmatic case
Boot's transactionTemplate bean runs a lambda in a transaction without a proxy, which also makes it immune to self-invocation. It suits code that is not a service method, such as a startup runner; article 28's seeder used one. In TransactionLab:
Integer stock = transactionTemplate.execute(status -> {
Product hub = products.findById(3L).orElseThrow();
hub.setStock(hub.getStock() + 10);
return hub.getStock();
});
log.info("execute returned {}, stocks after: {}", stock, stocks());
transactionTemplate.executeWithoutResult(status -> {
Product hub = products.findById(3L).orElseThrow();
hub.setStock(0);
status.setRollbackOnly();
});
log.info("stocks after: {}", stocks());JpaTransactionManager: Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(897736003<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5f84e764]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(897736003<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(897736003<open>)]
SQL: update products set category_id=?,name=?,price=?,sku=?,stock=? where id=?
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: execute returned 19, stocks after: [KB-01=12, MS-01=0, HUB-07=19]
JpaTransactionManager: Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(1244172753<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@7b6d88d1]
JpaTransactionManager: Found thread-bound EntityManager [SessionImpl(1244172753<open>)] for JPA transaction
JpaTransactionManager: Participating in existing transaction
SQL: select p1_0.id,p1_0.category_id,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
JpaTransactionManager: Transactional code has requested rollback
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(1244172753<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
TransactionLab: stocks after: [KB-01=12, MS-01=0, HUB-07=19]The transaction has no method to be named after, hence name [null]. The lambda's return value comes back from execute; a thrown RuntimeException would roll back, and status.setRollbackOnly() rolls back without an exception. For service code, the annotation stays the norm.
Transactions also show up in tests: a test method annotated with @Transactional is rolled back when it ends. In a two-test class on H2, the product saved inside the transactional test was gone in the next test, which counted 3 products instead of 4; Chapter 6 covers testing.
@Transactional behaviour at a glance
Every row is one of the runs above, on PostgreSQL 18.6 unless it says H2.
| Situation | Transaction active? | Outcome |
|---|---|---|
place() without @Transactional, reserveStock without save, valid order | no, one per repository call | 201, order and lines inserted, stock unchanged |
place() without @Transactional, save in reserveStock, second product short | no, one per repository call | 409, KB-01 committed at 8, no order: a partial write |
@Transactional place(), InsufficientStockException extends RuntimeException | yes | rolled back, 409, nothing written |
@Transactional place(), valid order | yes | committed, 201, order, lines and both stock changes |
@Transactional place(), InsufficientStockException extends Exception | yes | committed, 409, KB-01 at 8, no order |
rollbackFor = InsufficientStockException.class, checked exception | yes | rolled back, 409 |
noRollbackFor = InsufficientStockException.class, unchecked exception | yes | committed, MS-01 from 1 to 0 |
exception caught inside placeAvailable(), thrown by a method without @Transactional | yes | committed, 201 with one line |
exception caught inside placeAvailable(), thrown by another bean's @Transactional method | yes, shared | rolled back, UnexpectedRollbackException, 500 |
DataIntegrityViolationException from save() caught inside @Transactional | yes, shared | rolled back, UnexpectedRollbackException |
three repository calls in a method without @Transactional | three separate ones | three commits |
the same calls in a @Transactional method | one | one commit |
readOnly = true, managed entity changed | yes, read-only | committed without an UPDATE, change lost |
readOnly = true, save() of a new entity | yes, transaction_read_only = on | ERROR: cannot execute INSERT in a read-only transaction, rolled back |
restock() called from another bean | yes | committed |
restock() called as this.restock() from restockAll() | no | no UPDATE, change lost |
@Transactional restockAllInOneTransaction() calling this.restock() | yes, one for both | committed |
@Transactional on a protected or package-private method | yes | transaction created |
@Transactional on a private method | no | not intercepted |
jakarta.transaction.Transactional, checked exception | yes | committed |
jakarta.transaction.Transactional(rollbackOn = IOException.class) | yes | rolled back |
TransactionTemplate.execute | yes | committed |
status.setRollbackOnly() in TransactionTemplate | yes | rolled back |
@Transactional test method (H2) | yes | rolled back after the test |
FAQ
What does @Transactional do in Spring Boot?
It makes the proxy of the bean begin a transaction before the method runs and commit or roll back when it ends, so all database work inside the method takes effect together or not at all. With the JPA starter, Spring Boot 4.1.1 configures one JpaTransactionManager named transactionManager, and its DEBUG log shows each Creating new transaction, Participating in existing transaction, commit and rollback.
Does @Transactional roll back on checked exceptions?
No, not by default. With InsufficientStockException extends Exception, the failing order logged Initiating transaction commit and committed the first product's stock change to PostgreSQL. Only RuntimeException and Error roll back unless you add rollbackFor, which appears in the log as -com.example.demo.product.InsufficientStockException and turned the same run into a rollback.
Why does @Transactional not work when the method is called from the same class?
Because the call does not go through the proxy. restockAll() calling restock() on this ran with TransactionSynchronizationManager.isActualTransactionActive() returning false, no Creating new transaction line for restock, and no UPDATE. Put @Transactional on the method called from outside, or move the called method to another bean.
Should @Transactional go on the service or the repository?
On the service method that performs one use case. Spring Data repositories are already transactional per call, @Transactional(readOnly = true) on SimpleJpaRepository with @Transactional on its writing methods, and one transaction per repository call is exactly what left two keyboards reserved for an order that was never saved.
What does readOnly = true do with Hibernate and PostgreSQL?
With Hibernate 7.4.5, a change to a managed entity in a read-only transaction was not flushed: the transaction committed without an UPDATE. On PostgreSQL 18.6 the connection was read-only too, transaction_read_only = on, and an INSERT failed with ERROR: cannot execute INSERT in a read-only transaction, SQLState 25006, arriving in Java as JpaSystemException.
What causes "Transaction silently rolled back because it has been marked as rollback-only"?
A @Transactional method of another bean, a repository method included, threw a RuntimeException inside your transaction, and your code caught it and returned normally. The inner proxy logged Participating transaction failed - marking existing transaction as rollback-only, the outer commit became a rollback, and UnexpectedRollbackException reached the client as HTTP 500. Check before calling instead of catching, or let the exception propagate.
Should I use jakarta.transaction.Transactional or Spring's @Transactional?
Spring's. Spring runs both, and both committed on a checked exception, but the Jakarta annotation only has value, rollbackOn and dontRollbackOn: no readOnly, timeout or isolation, and rollback rules under different names than rollbackFor and noRollbackFor.
Conclusion
A transaction is what makes a use case all or nothing. Without one, each repository call committed on its own: an order either lost its stock changes or left stock reserved for an order that did not exist. With @Transactional on OrderService.place, the injected OrderService$$SpringCGLIB$$0 proxy began one JpaTransactionManager transaction, every repository call joined it, and the method's outcome decided between commit and rollback, all visible in the DEBUG log.
The rules that decide are few and easy to get wrong: unchecked exceptions roll back and checked ones commit unless rollbackFor says otherwise; an exception caught inside the method commits, unless another transactional proxy already marked the transaction rollback-only; readOnly = true drops entity changes and makes PostgreSQL refuse writes; and the annotation only acts on calls that pass through the proxy, which rules out this calls and private methods. The place for it is the public service method that is the use case, with readOnly = true at class level for services that mostly read.
The schema all of this ran against was still created by ddl-auto=create. The next article replaces it with database migrations: Flyway (or Liquibase), and why ddl-auto should not manage a production schema.