Transactions are the key mechanism for safely working with databases. Let's break down, using an online store example, why they're so important.
Imagine: a customer places an order. Sounds simple, but in reality we need to:
- Debit money from the customer's account
- Reserve the product in the warehouse
- Create an order record in the database
What can go wrong? Pretty much anything! For example, the money might be debited, but the server "crashes" before the order is created. Without transactions this becomes a real support nightmare. Fortunately, transactions solve this: either all operations succeed, or the system automatically rolls everything back.
Setting up TransactionManager
Before writing code, let's make sure our app is ready to handle transactions. TransactionManager is the heart of the transactional mechanism in Spring. It coordinates the execution of operations.
If you're using JPA/Hibernate (which we will), the TransactionManager setup looks simple.
Adding dependencies
The first thing we'll do is include dependencies for JPA and the database (for example, H2 Database for tests). Make sure your pom.xml contains the following:
<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>
Database configuration
In application.properties we'll put the settings to connect to H2:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
TransactionManager configuration
Spring Boot will configure TransactionManager automatically if you use spring-boot-starter-data-jpa. That means we're already ready to work with transactions. Hooray!
Implementing a transaction: creating a sample application
Now let's build a small app that models placing an order in an online store. Our tasks:
- Save order info.
- Update product available quantity.
- If something goes wrong, roll back all changes.
Creating entities
Start with the database and entities. We'll have two tables: Order and Product.
Product entity
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class Product {
@Id
private Long id;
private String name;
private int stock; // Stock count
// Getters and setters (you can use Lombok)
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getStock() {
return stock;
}
public void setStock(int stock) {
this.stock = stock;
}
}
Order entity
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private int quantity;
// Getters and setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getProductId() {
return productId;
}
public void setProductId(Long productId) {
this.productId = productId;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}
Creating repositories
Now create repositories for our entities. We'll use JpaRepository.
ProductRepository
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
// No additional methods needed for now
}
OrderRepository
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Long> {
// No additional methods needed for now
}
Managing transactions via @Transactional
Now the fun part! Let's create a service that:
- Registers an order.
- Rolls back changes if there's not enough product in stock.
OrderService
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public OrderService(ProductRepository productRepository, OrderRepository orderRepository) {
this.productRepository = productRepository;
this.orderRepository = orderRepository;
}
@Transactional
public void placeOrder(Long productId, int quantity) {
// 1. Fetch the product
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("Product not found!"));
// 2. Check stock availability
if (product.getStock() < quantity) {
throw new RuntimeException("Not enough stock for product!");
}
// 3. Decrease stock
product.setStock(product.getStock() - quantity);
productRepository.save(product);
// 4. Create and save the order
Order order = new Order();
order.setProductId(productId);
order.setQuantity(quantity);
orderRepository.save(order);
// Commit changes only if everything went well
System.out.println("Order placed successfully!");
}
}
What does the @Transactional annotation do here? It guarantees that all changes are either committed or rolled back if an error occurs. For example, if there's not enough product in stock, the method throws an exception, and changes are rolled back.
Testing transactions
Let's test how this works! We'll write a simple test class.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.junit.jupiter.api.Test;
@SpringBootTest
public class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@Test
public void testPlaceOrder_Success() {
// Create a test product
Product product = new Product();
product.setId(1L);
product.setName("Test Product");
product.setStock(10);
productRepository.save(product);
// Place the order
orderService.placeOrder(1L, 2);
// Check product stock
Product updatedProduct = productRepository.findById(1L).get();
assert updatedProduct.getStock() == 8;
}
@Test
public void testPlaceOrder_NotEnoughStock() {
// Create a test product
Product product = new Product();
product.setId(2L);
product.setName("Another Product");
product.setStock(5);
productRepository.save(product);
try {
// Try to order more than available in stock
orderService.placeOrder(2L, 10);
} catch (Exception e) {
System.out.println(e.getMessage()); // Expect "Not enough stock for product!"
}
// Verify that the product didn't change
Product updatedProduct = productRepository.findById(2L).get();
assert updatedProduct.getStock() == 5;
}
}
Summary
Now we have a transactional service that:
- Ensures data integrity.
- Automatically rolls back changes on errors.
- Is easy to test.
In practice, these approaches make applications reliable and predictable. And yeah, don't forget to give yourself a star for today's progress!
GO TO FULL VERSION