Command Palette

Search for a command to run...

[Spring Boot Basics] @Transactional in Spring Boot: What a Transaction Is, Where to Put It and When It Rolls Back

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.

@Transactional above three SQL statements held in one transaction, which either all commit or all roll back

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:

src/main/resources/application.properties
spring.application.name=demo
spring.jpa.open-in-view=false
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG 
src/main/resources/application-postgres.properties
spring.datasource.url=jdbc:postgresql://localhost:55430/shop
spring.datasource.username=shop
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=create
Bash
docker run -d --name sb-a30-pg -e POSTGRES_USER=shop -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=shop -p 55430:5432 postgres:18
Bash
./gradlew -q bootJar
Bash
java -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.

idSKUProductStock
1KB-01Mechanical keyboard10
2MS-01Wireless mouse1
3HUB-07USB-C hub5

The state of the database is read with psql inside the container after each request:

Bash
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:

src/main/java/com/example/demo/order/OrderService.java
    @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:

Bash
curl -i -s -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":2},{"productId":3,"quantity":1}]}' http://localhost:8130/api/customers/1/orders
Text
HTTP/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}
Text
 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:

Text
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 commit

Four 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:

src/main/java/com/example/demo/product/ProductService.java
    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.

Bash
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
Text
HTTP/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"}
Text
 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:

Text
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 commit

save 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

src/main/java/com/example/demo/order/OrderService.java
    @Transactional
    public Order place(Long customerId, List<OrderItem> items) {

The same failing request returned the same 409 body, and this time:

Text
 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:

Text
 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:

Bash
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'
src/main/java/com/example/demo/lab/TransactionLab.java
    @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();
    }
Text
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:

Text
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 transaction

The end of the successful order's log:

Text
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 lineWhat happened
Creating new transaction with name [...OrderService.place]: PROPAGATION_REQUIRED,ISOLATION_DEFAULTthe proxy of OrderService began a transaction named after the method, with its settings
Opened new EntityManager [SessionImpl(...)] for JPA transactionone 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 transactiona repository call joined the running transaction instead of opening its own; one pair per call, four here
Initiating transaction commit, Committing JPA transactionthe method returned; Hibernate flushed the pending UPDATEs during the commit
Initiating transaction rollback, Rolling back JPA transactionthe method threw; nothing was flushed
Closing JPA EntityManager after transactionthe 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:

Text
TransactionLab: OrderService bean class: com.example.demo.order.OrderService$$SpringCGLIB$$0
TransactionLab: its superclass: com.example.demo.order.OrderService
TransactionLab: AopUtils.isCglibProxy: true

The 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:

  1. OrderController calls service.place(customerId, items) on the proxy.
  2. The proxy reads the @Transactional attributes of place and asks JpaTransactionManager to begin: Creating new transaction.
  3. The proxy calls the real place. Repository calls inside it find the transaction bound to the thread and join it.
  4. If place returns, the proxy commits and returns the Order to 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.

What runs around one call to a @Transactional method: the caller calls the injected OrderService$$SpringCGLIB$$0 proxy, JpaTransactionManager logs Creating new transaction and Opened new EntityManager, place() runs with one Participating in existing transaction per repository call, then either commit and a 201 or rollback, a rethrown exception and a 409, each with its verbatim log lines

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.place directly 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:

Bash
javap -v -cp spring-data-jpa-4.1.1.jar org.springframework.data.jpa.repository.support.SimpleJpaRepository
Where in SimpleJpaRepository 4.1.1Annotation
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 readsnone 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:

src/main/java/com/example/demo/lab/RepositoryCalls.java
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():

Text
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 products

readInOneTransaction():

Text
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 transaction

Three 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:

src/main/java/com/example/demo/order/OrderService.java
@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:

Text
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 rollback

findById 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.

A decision tree: if no exception left the method, including one caught inside, the transaction commits; otherwise a matching rollbackFor rule rolls back and a matching noRollbackFor rule commits; with no matching rule, a RuntimeException or Error rolls back and a checked exception commits; each outcome shows the log line observed for it

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:

src/main/java/com/example/demo/product/InsufficientStockException.java
public class InsufficientStockException extends RuntimeException { 
public class InsufficientStockException extends Exception { 
src/main/java/com/example/demo/product/ProductService.java
    public Product reserveStock(Long id, int quantity) { 
    public Product reserveStock(Long id, int quantity) throws InsufficientStockException { 
src/main/java/com/example/demo/order/OrderService.java
    @Transactional
    public Order place(Long customerId, List<OrderItem> items) { 
    public Order place(Long customerId, List<OrderItem> items) throws InsufficientStockException { 
src/main/java/com/example/demo/order/OrderController.java
    @PostMapping("/api/customers/{customerId}/orders")
    public ResponseEntity<OrderResponse> place(@PathVariable Long customerId,
                                               @Valid @RequestBody PlaceOrderRequest request) { 
                                               @Valid @RequestBody PlaceOrderRequest request) throws InsufficientStockException { 
src/main/java/com/example/demo/common/GlobalExceptionHandler.java
    @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:

Text
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
Text
 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:

src/main/java/com/example/demo/order/OrderService.java
    @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:

Text
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 transaction

psql 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:

src/main/java/com/example/demo/lab/RollbackRulesLab.java
    @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:

Text
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:

src/main/java/com/example/demo/product/InsufficientStockException.java
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:

src/main/java/com/example/demo/order/OrderService.java
    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);
    }
src/main/java/com/example/demo/order/OrderController.java
    @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); 
Bash
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'
Text
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}
Text
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
Text
 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

src/main/java/com/example/demo/lab/ReadOnlyLab.java
    @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):

Text
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:

src/main/java/com/example/demo/lab/ReadOnlyLab.java
    @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));
    }
Text
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 transaction

The exception chain TransactionLab printed:

Text
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') returned on, read through JdbcClient inside the JPA transaction, so PostgreSQL knew about the flag, not only Hibernate.
  • save did not get its own read-write transaction. Its @Transactional without readOnly joined the running read-only transaction, and the INSERT failed with SQLState 25006.
  • The exception is generic. It arrives as JpaSystemException, not as one of the specific DataAccessException types, so a handler written for DataIntegrityViolationException does 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:

src/main/java/com/example/demo/lab/Restocker.java
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:

Text
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):

Text
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

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:

Text
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:

src/main/java/com/example/demo/lab/RestockBatch.java
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);
        }
    }
}
Text
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

src/main/java/com/example/demo/lab/VisibilityLab.java
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:

src/main/java/com/example/demo/lab/TransactionLab.java
        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:

Text
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 = false

On 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.

Bash
javap -c -p -cp spring-tx-7.0.9.jar org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource
Text
  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: areturn

In 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:

src/main/java/com/example/demo/product/ProductService.java
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:

Bash
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'
Text
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:

Text
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
Text
 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.

Five steps over time: placeAvailable() begins a transaction; reserveStock(2, 3) joins it and throws InsufficientStockException; JpaTransactionManager logs Participating transaction failed and marks the shared transaction rollback-only; placeAvailable() catches the exception, saves the order and returns; the outer proxy commits, the transaction is rolled back and UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only reaches the client as HTTP/1.1 500

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:

src/main/java/com/example/demo/lab/RollbackRulesLab.java
    @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:

Text
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-only

Same 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 reserveStock had no proxy between the exception and the catch, 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's existsBySku answers) and call it only when it will succeed.
  • Or let the exception propagate. place does 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:

Bash
./gradlew dependencies --configuration compileClasspath
Text
|    |    |    +--- 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.1

Their attributes, read with javap:

Bash
javap -cp spring-tx-7.0.9.jar org.springframework.transaction.annotation.Transactional
Text
Compiled 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();
}
Bash
javap -cp jakarta.transaction-api-2.0.1.jar jakarta.transaction.Transactional
Text
Compiled 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:

src/main/java/com/example/demo/lab/JakartaLab.java
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:

Text
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 @Transactionaljakarta.transaction.Transactional
Roll back for these exceptionsrollbackFor, rollbackForClassNamerollbackOn
Do not roll back for thesenoRollbackFor, noRollbackForClassNamedontRollbackOn
Propagationpropagationvalue, a Transactional.TxType
readOnly, timeout, isolation, transactionManager, labelyesno such attributes
Checked exception without a rule, in these runscommitcommit

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:

src/main/java/com/example/demo/lab/TransactionLab.java
        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());
Text
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.

SituationTransaction active?Outcome
place() without @Transactional, reserveStock without save, valid orderno, one per repository call201, order and lines inserted, stock unchanged
place() without @Transactional, save in reserveStock, second product shortno, one per repository call409, KB-01 committed at 8, no order: a partial write
@Transactional place(), InsufficientStockException extends RuntimeExceptionyesrolled back, 409, nothing written
@Transactional place(), valid orderyescommitted, 201, order, lines and both stock changes
@Transactional place(), InsufficientStockException extends Exceptionyescommitted, 409, KB-01 at 8, no order
rollbackFor = InsufficientStockException.class, checked exceptionyesrolled back, 409
noRollbackFor = InsufficientStockException.class, unchecked exceptionyescommitted, MS-01 from 1 to 0
exception caught inside placeAvailable(), thrown by a method without @Transactionalyescommitted, 201 with one line
exception caught inside placeAvailable(), thrown by another bean's @Transactional methodyes, sharedrolled back, UnexpectedRollbackException, 500
DataIntegrityViolationException from save() caught inside @Transactionalyes, sharedrolled back, UnexpectedRollbackException
three repository calls in a method without @Transactionalthree separate onesthree commits
the same calls in a @Transactional methodoneone commit
readOnly = true, managed entity changedyes, read-onlycommitted without an UPDATE, change lost
readOnly = true, save() of a new entityyes, transaction_read_only = onERROR: cannot execute INSERT in a read-only transaction, rolled back
restock() called from another beanyescommitted
restock() called as this.restock() from restockAll()nono UPDATE, change lost
@Transactional restockAllInOneTransaction() calling this.restock()yes, one for bothcommitted
@Transactional on a protected or package-private methodyestransaction created
@Transactional on a private methodnonot intercepted
jakarta.transaction.Transactional, checked exceptionyescommitted
jakarta.transaction.Transactional(rollbackOn = IOException.class)yesrolled back
TransactionTemplate.executeyescommitted
status.setRollbackOnly() in TransactionTemplateyesrolled back
@Transactional test method (H2)yesrolled 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.

Related Posts

[Spring Boot Basics] Validation in Spring Boot: Bean Validation Annotations, @Valid and Custom Validators

Bean Validation in Spring Boot 4.1.1 with Hibernate Validator: spring-boot-starter-validation, @NotNull vs @NotEmpty vs @NotBlank, @Size, @DecimalMin, @Digits, @Email and @Pattern on request DTO records, @Valid on @RequestBody and the default 400, nested objects and lists, @PathVariable and @RequestParam validation and the @Validated 500 trap, validation groups, ValidationMessages.properties and Accept-Language, custom ConstraintValidator and cross-field constraints, and validation in the service layer.

[Spring Boot Basics] Unit Testing in Spring Boot: JUnit 6, AssertJ and Mockito for the Service Layer

Unit testing the service layer of a Spring Boot 4.1.1 application with JUnit, AssertJ and Mockito: what a unit test replaces, the Gradle test task and its report, a new test instance per method proven by identity, @Nested and parameterized display names in JUnit 6, the BigDecimal isEqualTo trap and soft assertions with their failure messages, @Mock with constructor injection versus @InjectMocks passing null, stubbing, verify and ArgumentCaptor, UnnecessaryStubbingException and PotentialStubbingProblem under strict stubs, a fixed Clock, and loading Mockito as a -javaagent to remove the self-attaching warning.

[Spring Boot Basics] Spring Framework vs Spring Boot: What Auto-Configuration, Starters and the Embedded Server Actually Do

Spring Framework versus Spring Boot 4.1.1: what a plain Spring web application made you write, what starters and the spring-boot-dependencies BOM replace it with, why Boot 4 renamed the web starter to spring-boot-starter-webmvc, how auto-configuration backs off, embedded Tomcat against a WAR, and a map of the Spring ecosystem.

[Spring Boot Basics] Logging in Spring Boot: SLF4J, Logback, Log Levels and Log Files

Logging in Spring Boot 4.1.1: SLF4J as the facade and Logback as the implementation, the jul-to-slf4j and log4j-to-slf4j bridges, parameterised and fluent logging, exceptions, log levels, the logger hierarchy and log groups, --debug versus --trace, the default log line pattern, logging.file.name with rotation, logback-spring.xml with springProfile, MDC and switching to Log4j2.