Understanding Transactions
Understanding Transactions
A transaction is a logical unit of work that includes one or more database operations. A transaction ensures that all the database operations are executed as a single unit of work. If any operation in a transaction fails, the entire transaction
is rolled back, ensuring that the data remains consistent. Spring Boot supports both programmatic and declarative transaction management. In programmatic transaction management, the developer has to manually define the transaction
boundaries and manage the transaction programmatically. In declarative transaction management, the transaction management is handled by the container, and the developer only has to define the transactional boundaries
using annotations.
Implement Transactions
To implement transactions in our Spring Boot application, we will be using Spring Data JPA. Spring Data JPA provides a @Transactional annotation that we can use to declare the transaction boundaries. When a method is annotated
with @Transactional, Spring will start a transaction before the method is executed and commit the transaction after the method completes. If the method throws an exception, Spring will automatically rollback the transaction.
Let’s say we have an application that allows users to transfer money between accounts. We want to make sure that each transfer is performed atomically, so that if any part of the transaction fails, the entire transaction is rolled back.
First, we need to define a model for our user accounts. Here’s an example of what our Account class might look like:
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal balance;
}
Next, we need to create a repository for our Account class. Here's an example of what our AccountRepository might look like:
@Modifying
@Query("update Account set balance = balance + :amount where id = :id")
void deposit(@Param("id") Long id, @Param("amount") BigDecimal amount);
@Modifying
@Query("update Account set balance = balance - :amount where id = :id")
void withdraw(@Param("id") Long id, @Param("amount") BigDecimal amount);
}
Our AccountRepository contains two custom queries that update the balance of an account. The deposit method increases the balance of an account by a certain amount, while the withdraw method decreases the balance of an
account by a certain amount. Now, we need to create a service that will handle the transfer of money between accounts. Here’s an example of what our TransferService might look like:
@Service
public class TransferService {
@Autowired
private AccountRepository accountRepo;
@Transactional
public void transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
accountRepo.withdraw(fromAccountId, amount);
accountRepo.deposit(toAccountId, amount);
}
}
The transfer method is annotated with @Transactional, which means t the entire transfer method will be executed within a transaction. Within the transfer method, we call the withdraw method to decrease the balance of
the fromAccount, and then we call the deposit method to increase the balance of the toAccount. If either of these methods throws an exception, the entire transaction will be rolled back. That’s it! With just a few lines of code, we
have implemented transactions in our Spring Boot application using Spring Data JPA.
Best Practices
Here are some best practices to keep in mind when implementing transactions with Spring Data JPA in a Spring Boot application:
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepo;
@Autowired
private EmailService emailService;
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepo;
@Service
@Transactional
public class EmailService {
@Service
public class BatchService {
@Autowired
private BatchRepository batchRepo;
@Transactional
public void processChunk(List<Item> chunk) {
for (Item item : chunk) {
batchRepo.updateItemStatus(item.getId(), Status.PROCESSED);
}
}
}
In this example, our BatchService processes a batch of items using a chunk size of 1000. For each chunk, we call the processChunk method, which updates the status of each item in the chunk using the batchRepository.
The processChunk method is annotated with @Transactional, which means that each chunk of updates will be executed within its own transactional boundary.
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepo;
@Autowired
private EmailService emailService;
@Service
@Transactional
public class EmailService {
If we want to make sure that the sendEmail method is executed within the same transactional boundary as the updateUserEmail method, we can set the Propagation parameter of the @Transactional annotation
to Propagation.REQUIRED. This will propagate the transaction to the sendEmail method, ensuring that both methods are executed within the same transactional boundary.
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepo;
@Autowired
private EmailService emailService;
@Transactional(propagation = Propagation.REQUIRED)
public void updateUserEmail(Long userId, String newEmail) {
User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new);
user.setEmail(newEmail);
userRepo.save(user);
emailService.sendEmail(user.getEmail(), "Email address updated.");
}
}
@Service
public class EmailService {
@Transactional(propagation = Propagation.MANDATORY)
public void sendEmail(String email, String message) {
// send email code
}
}
In this updated example, we’ve set the Propagation parameter of the updateUserEmail method to Propagation.REQUIRED. We've also added the Propagation.MANDATORY parameter to the sendEmail method of
the EmailService. This means that the sendEmail method must be executed within a transactional boundary, and if no transactional boundary exists, an exception will be thrown.
By using the Propagation parameter judiciously, we can ensure that our transactions are executed correctly and efficiently, and that each method is executed within the appropriate transactional boundary.
@Service
@Transactional
public class AccountService {
@Autowired
private AccountRepository accountRepo;
@Service
@Transactional
public class AccountService {
@Autowired
private AccountRepository accountRepo;
In this updated example, we’ve added a custom AccountNotFoundException that is thrown if one of the accounts cannot be found. This allows us to handle the exception more gracefully and provide more information about the error.
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepo;
@Autowired
private OrderRepository orderRepo;
In this example, we’re using two separate repositories (userRepository and orderRepository) to retrieve and update data. Each repository has its own EntityManager instance, which means that each method within
our UserService is using a different EntityManager instance.
To use a single EntityManager instance, we can inject the EntityManager directly into our service instead of using separate repositories.
@Service
@Transactional
public class UserService {
@PersistenceContext
private EntityManager em;
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepo;
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
@Version
private Long version;
}
We can then update our updateUserEmail method to check the version number of the User entity before saving any changes.
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepo;
@Service
@Transactional
public class UserService {
@Autowired
private OrderRepository orderRepo;
@Service
@Transactional(readOnly = true)
public class UserService {
@Autowired
private OrderRepository orderRepository;
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepository;
@Modifying
@Query("update User u set u.email = :newEmail where u.id = :userId")
void updateUserEmail(@Param("userId") Long userId, @Param("newEmail") String newEmail);
}
We can then update our updateUserEmail method in UserService class to call the updateUserEmail method of our UserRepository.
By following these best practices, you can ensure that your transactions are implemented efficiently, safely, and effectively in your Spring Boot application using Spring Data JPA. In addition to the @Transactional annotation, Spring
Data JPA provides several other transaction-related annotations, such as @TransactionalEventListener, which allows us to listen for transaction-related events. We can also use programmatic transaction management by using
the TransactionTemplate or PlatformTransactionManager classes.