Skip to content
CampusEduX

Database · Lesson 64 of 95

@Transactional

Learn @Transactional in Spring Boot: commit, rollback, checked vs unchecked exceptions, rollbackFor and self-invocation, with a wallet transfer demo.

8 min read

You send 100 rupees to a friend from your wallet app. The app takes 100 rupees from your balance. Then the server crashes before it adds the money to your friend. You have lost 100 rupees and nobody has it. This is the nightmare that transactions were invented to prevent.

Let's learn how the @Transactional annotation works in Spring Boot, what "all or nothing" really means, and which small details make a rollback happen or not.

What is @Transactional?

Think of a cashier who is counting cash to hand over to a customer. Either the whole pile is handed over and the register is updated, or nothing changes at all. Nobody accepts a half-finished payment.

When a method marked @Transactional finishes normally, Spring commits the work. When it throws an exception of the right kind, Spring rolls the work back, and the database looks as if the method never ran.

Why is it used?

Real business actions almost always change more than one row:

  • A money transfer changes two wallets.
  • An order in a cinema app reserves seats, creates a booking and reduces the stock of snacks.
  • A hospital admission creates a patient record, a bed allocation and a bill.

If any step fails, you never want to keep the earlier steps. Databases guarantee this with the ACID rules: Atomic (all or nothing), Consistent (rules stay true), Isolated (parallel users do not disturb each other) and Durable (committed data survives a crash). @Transactional gives you these guarantees without writing begin, commit and rollback by hand.

How it works

Here is the journey of one transfer with @Transactional.

text
transfer(Asha -> Ravi, 100) | v BEGIN transaction | v debit Asha (1000 -> 900) | v error? -- yes --> ROLLBACK | (900 -> 1000) no | v credit Ravi (500 -> 600) | v COMMIT

Everything between BEGIN and COMMIT is one unit. If an error appears in the middle, the database undoes the debit, and Asha's balance goes back to 1000.

Now let's see who does the wrapping. Spring does not change your class. It creates a proxy, a thin object that stands in front of your service bean.

text
Caller | v Spring proxy: BEGIN | v Your method runs | +--> returns normally: COMMIT | +--> RuntimeException: ROLLBACK

Your controller calls the proxy, the proxy starts the transaction, and only then does your real method run. When your method ends, the proxy decides between commit and rollback. Because the proxy is involved, one rule follows: the call must come through the Spring bean from outside. If a method in the same class calls its own @Transactional method, the call skips the proxy and no transaction starts. This is called self-invocation.

Which exceptions roll back?

By default, Spring rolls back on unchecked exceptions (RuntimeException and Error). It commits on checked exceptions such as IOException. That surprises many people, so you will see it in the output below. To roll back on a checked exception, write rollbackFor.

SituationDefault result
Method returns normallyCommit
RuntimeException thrownRollback
Checked exception, e.g. IOExceptionCommit
Checked exception with rollbackForRollback

Real-Life Example

Think of booking a train ticket at a counter. The clerk fills the form, takes your money, prints the ticket and marks the seat as taken. If the printer jams after the money is taken, a good clerk cancels the whole thing and gives your money back. He does not keep the money and say the seat is still free. @Transactional is that good clerk: if any step fails, every earlier step is cancelled.

Code Example

Let's build PayNow, a small wallet app. Two wallets start with Asha at 1000 and Ravi at 500. We try a transfer that fails in the middle, and we run it four ways: with no transaction, with @Transactional, with a checked exception, and with rollbackFor.

text
paynow/ ├─ pom.xml └─ src/main/ ├─ java/com/paynow/wallet/ │ ├─ WalletApplication.java │ ├─ Wallet.java │ ├─ WalletRepository.java │ └─ TransferService.java └─ resources/ └─ application.properties

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.paynow</groupId> <artifactId>wallet</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: application.properties in src/main/resources

properties
spring.main.banner-mode=off logging.level.root=warn

File: Wallet.java in package com.paynow.wallet

java
package com.paynow.wallet; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @Entity public class Wallet { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String owner; private long balance; protected Wallet() { } public Wallet(String owner, long balance) { this.owner = owner; this.balance = balance; } public String getOwner() { return owner; } public long getBalance() { return balance; } public void setBalance(long balance) { this.balance = balance; } }

File: WalletRepository.java in package com.paynow.wallet

java
package com.paynow.wallet; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface WalletRepository extends JpaRepository<Wallet, Long> { Optional<Wallet> findByOwner(String owner); }

File: TransferService.java in package com.paynow.wallet

java
package com.paynow.wallet; import java.io.IOException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class TransferService { private final WalletRepository wallets; public TransferService(WalletRepository wallets) { this.wallets = wallets; } public void reset() { wallets.deleteAll(); wallets.save(new Wallet("Asha", 1000)); wallets.save(new Wallet("Ravi", 500)); } public String balances() { return "Asha " + balanceOf("Asha") + ", Ravi " + balanceOf("Ravi"); } public void noTransaction(long amount) { change("Asha", -amount); throw new IllegalStateException("Bank server down"); } @Transactional public void safe(long amount) { change("Asha", -amount); throw new IllegalStateException("Bank server down"); } @Transactional public void checkedDefault(long amount) throws IOException { change("Asha", -amount); throw new IOException("Bank server down"); } @Transactional(rollbackFor = IOException.class) public void checkedRollback(long amount) throws IOException { change("Asha", -amount); throw new IOException("Bank server down"); } private void change(String owner, long delta) { Wallet wallet = wallets.findByOwner(owner).orElseThrow(); wallet.setBalance(wallet.getBalance() + delta); wallets.save(wallet); } private long balanceOf(String owner) { return wallets.findByOwner(owner).orElseThrow().getBalance(); } }

File: WalletApplication.java in package com.paynow.wallet

java
package com.paynow.wallet; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class WalletApplication { public static void main(String[] args) { SpringApplication.run(WalletApplication.class, args); } interface Attempt { void run() throws Exception; } @Bean CommandLineRunner demo(TransferService service) { return args -> { run(service, "1. No @Transactional", () -> service.noTransaction(100)); run(service, "2. @Transactional", () -> service.safe(100)); run(service, "3. Checked exception", () -> service.checkedDefault(100)); run(service, "4. With rollbackFor", () -> service.checkedRollback(100)); }; } private static void run(TransferService service, String title, Attempt attempt) { service.reset(); System.out.println(title); System.out.println(" before: " + service.balances()); try { attempt.run(); } catch (Exception e) { System.out.println(" failed: " + e.getMessage()); } System.out.println(" after: " + service.balances()); } }

Run it:

bash
mvn spring-boot:run

Output:

text
1. No @Transactional before: Asha 1000, Ravi 500 failed: Bank server down after: Asha 900, Ravi 500 2. @Transactional before: Asha 1000, Ravi 500 failed: Bank server down after: Asha 1000, Ravi 500 3. Checked exception before: Asha 1000, Ravi 500 failed: Bank server down after: Asha 900, Ravi 500 4. With rollbackFor before: Asha 1000, Ravi 500 failed: Bank server down after: Asha 1000, Ravi 500

In the first run, Asha lost 100 rupees and Ravi never received them. That is the exact nightmare from the start. With @Transactional the debit was undone. The checked exception committed the half-finished transfer, and only rollbackFor fixed it.

Code Explained

  • noTransaction() has no annotation. Each repository call is its own tiny transaction, so the debit is saved for good before the exception is thrown.
  • safe() runs both steps inside one transaction. The IllegalStateException is unchecked, so Spring rolls everything back.
  • checkedDefault() throws IOException, which is checked. Spring commits, so the debit stays.
  • checkedRollback() adds rollbackFor = IOException.class, so Spring rolls back even for this checked exception.
  • reset() and balances() have no annotation on purpose. They are simple reads and writes, and each repository call handles itself.
  • The Attempt interface only lets the demo pass code that may throw a checked exception.

Common Mistakes

  • Expecting rollback on checked exceptions. Add rollbackFor = Exception.class when your methods throw checked exceptions.
  • Catching the exception inside the method. If you catch and swallow the error, Spring sees a normal return and commits. Rethrow, or mark the transaction rollback-only.
  • Using it on private methods. The proxy cannot intercept them, so the annotation does nothing. Use public methods.
  • Long transactions. A transaction that calls a slow web service keeps database locks open. Do the slow call outside the transaction.
  • Using `readOnly = true` for writes. It is a hint for reads, and writes may be ignored or rejected depending on the setup.

Interview Questions

What does @Transactional do?

Ans:It wraps a method in a database transaction. Spring commits when the method returns normally and rolls back when it throws a runtime exception.

Which exceptions cause a rollback by default?

Ans:Unchecked exceptions, meaning RuntimeException and Error. Checked exceptions commit unless you set rollbackFor.

Why does self-invocation ignore @Transactional?

Ans:Spring applies the annotation through a proxy. A call from inside the same class never passes through the proxy, so no transaction logic runs.

What are the ACID properties?

Ans:Atomicity, Consistency, Isolation and Durability. They describe what a reliable database transaction guarantees.

Key Points to Remember

  • A transaction makes several database steps succeed or fail together.
  • @Transactional on a public service method wraps it in one transaction.
  • Runtime exceptions roll back. Checked exceptions commit unless rollbackFor says otherwise.
  • The proxy makes the annotation work, so self-invocation and private methods are not covered.
  • Keep transactions short and put them on the service layer.

Frequently Asked Questions

Where should I put @Transactional?

On the public methods of your service classes. A controller should only receive the request and call a service, and a repository already runs each of its methods in a transaction.

What does readOnly = true do in @Transactional?

It tells Spring and Hibernate that the method only reads data. Hibernate can skip some work, such as dirty checking, and the database may optimise the query. Use it for read methods.

Do I need @Transactional for a simple save()?

No. The repository's save() is already transactional. You need it when several operations must succeed together.

What is transaction propagation?

It decides what happens when a transactional method calls another one. The default, REQUIRED, joins the existing transaction. REQUIRES_NEW suspends it and starts a fresh one.

Practice Problems

Try each problem on your own first. Both use the H2 in-memory database, so nothing needs to be installed.

Easy: Bakery Stock Guard

Sunrise Bakery keeps the stock of each cake. Write a sell(name, quantity) method that reduces the stock and then checks it. If the stock would go below zero, throw an IllegalArgumentException, and make sure the stock is not changed in that case. Sell 3 cakes when 5 are in stock, then try to sell 4 more.

Show answer
The reduction and the check sit in one transaction. When the check fails, the runtime exception makes Spring roll back the change.

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.sunrise</groupId> <artifactId>stock</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: application.properties in src/main/resources

properties
spring.main.banner-mode=off logging.level.root=warn

File: Cake.java in package com.sunrise.stock

java
package com.sunrise.stock; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @Entity public class Cake { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private int stock; protected Cake() { } public Cake(String name, int stock) { this.name = name; this.stock = stock; } public int getStock() { return stock; } public void setStock(int stock) { this.stock = stock; } }

File: CakeRepository.java in package com.sunrise.stock

java
package com.sunrise.stock; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface CakeRepository extends JpaRepository<Cake, Long> { Optional<Cake> findByName(String name); }

File: CakeService.java in package com.sunrise.stock

java
package com.sunrise.stock; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class CakeService { private final CakeRepository cakes; public CakeService(CakeRepository cakes) { this.cakes = cakes; } public void addCake(String name, int stock) { cakes.save(new Cake(name, stock)); } public int stockOf(String name) { return cakes.findByName(name).orElseThrow().getStock(); } @Transactional public void sell(String name, int quantity) { Cake cake = cakes.findByName(name).orElseThrow(); cake.setStock(cake.getStock() - quantity); cakes.saveAndFlush(cake); if (cake.getStock() < 0) { throw new IllegalArgumentException("Not enough stock"); } } }

File: StockApplication.java in package com.sunrise.stock

java
package com.sunrise.stock; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class StockApplication { public static void main(String[] args) { SpringApplication.run(StockApplication.class, args); } @Bean CommandLineRunner demo(CakeService service) { return args -> { service.addCake("Pineapple", 5); service.sell("Pineapple", 3); System.out.println("After selling 3: " + service.stockOf("Pineapple")); try { service.sell("Pineapple", 4); } catch (IllegalArgumentException e) { System.out.println("Refused: " + e.getMessage()); } System.out.println("After refusal: " + service.stockOf("Pineapple")); }; } }

Running the app prints:

text
After selling 3: 2 Refused: Not enough stock After refusal: 2

Medium: Cinema Booking and Card Decline

CineGo books seats in two steps: it saves a Booking row, then charges the card. The card check throws a checked PaymentException when the card is declined. Make the booking disappear and the seats return when the card is declined. Book once with a good card and once with a declined card, and print the number of bookings and the seats left after each.

Show answer
The booking row and the seat count change in one transaction. With rollbackFor, the declined card undoes both.

File: pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.cinego</groupId> <artifactId>booking</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>

File: application.properties in src/main/resources

properties
spring.main.banner-mode=off logging.level.root=warn

File: Screening.java in package com.cinego.booking

java
package com.cinego.booking; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @Entity public class Screening { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String movie; private int seatsLeft; protected Screening() { } public Screening(String movie, int seatsLeft) { this.movie = movie; this.seatsLeft = seatsLeft; } public Long getId() { return id; } public int getSeatsLeft() { return seatsLeft; } public void setSeatsLeft(int seatsLeft) { this.seatsLeft = seatsLeft; } }

File: Booking.java in package com.cinego.booking

java
package com.cinego.booking; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @Entity public class Booking { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long screeningId; private int seats; protected Booking() { } public Booking(Long screeningId, int seats) { this.screeningId = screeningId; this.seats = seats; } }

File: PaymentException.java in package com.cinego.booking

java
package com.cinego.booking; public class PaymentException extends Exception { public PaymentException(String message) { super(message); } }

File: ScreeningRepository.java in package com.cinego.booking

java
package com.cinego.booking; import org.springframework.data.jpa.repository.JpaRepository; public interface ScreeningRepository extends JpaRepository<Screening, Long> { }

File: BookingRepository.java in package com.cinego.booking

java
package com.cinego.booking; import org.springframework.data.jpa.repository.JpaRepository; public interface BookingRepository extends JpaRepository<Booking, Long> { }

File: BookingService.java in package com.cinego.booking

java
package com.cinego.booking; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class BookingService { private final ScreeningRepository screenings; private final BookingRepository bookings; public BookingService(ScreeningRepository screenings, BookingRepository bookings) { this.screenings = screenings; this.bookings = bookings; } public Long open(String movie, int seats) { return screenings.save(new Screening(movie, seats)).getId(); } @Transactional(rollbackFor = PaymentException.class) public void book(Long screeningId, int seats, boolean cardOk) throws PaymentException { Screening screening = screenings.findById(screeningId).orElseThrow(); bookings.save(new Booking(screeningId, seats)); screening.setSeatsLeft(screening.getSeatsLeft() - seats); screenings.save(screening); bookings.flush(); if (!cardOk) { throw new PaymentException("Card declined"); } } public String status(Long screeningId) { return "bookings=" + bookings.count() + ", seatsLeft=" + screenings.findById(screeningId).orElseThrow().getSeatsLeft(); } }

File: BookingApplication.java in package com.cinego.booking

java
package com.cinego.booking; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class BookingApplication { public static void main(String[] args) { SpringApplication.run(BookingApplication.class, args); } @Bean CommandLineRunner demo(BookingService service) throws Exception { return args -> { Long id = service.open("Monsoon Express", 10); service.book(id, 2, true); System.out.println("Good: " + service.status(id)); try { service.book(id, 3, false); } catch (PaymentException e) { System.out.println("Failed: " + e.getMessage()); } System.out.println("Declined: " + service.status(id)); }; } }

Running the app prints:

text
Good: bookings=1, seatsLeft=8 Failed: Card declined Declined: bookings=1, seatsLeft=8