0% found this document useful (0 votes)
16 views8 pages

Understanding Transactions

A transaction is a logical unit of work that ensures database operations are executed atomically. Spring Boot supports declarative and programmatic transaction management. The document discusses implementing transactions in a Spring Boot application using Spring Data JPA, including defining transaction boundaries with annotations and ensuring atomicity.

Uploaded by

Lars
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
16 views8 pages

Understanding Transactions

A transaction is a logical unit of work that ensures database operations are executed atomically. Spring Boot supports declarative and programmatic transaction management. The document discusses implementing transactions in a Spring Boot application using Spring Data JPA, including defining transaction boundaries with annotations and ensuring atomicity.

Uploaded by

Lars
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 8

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.

Set Up Spring Boot Application


Configure our application properties to connect to the H2 database. Add the following properties in our application.properties file:
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop

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:

public interface AccountRepository extends JpaRepository<Account, Long> {

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

Keep Transaction scopes as small as possible


Transactions should only be used for the smallest possible unit of work to minimize the potential for locking or blocking other database transactions. Let’s say we have a service that performs two operations on our database: it updates a
user’s email address and then sends an email to the user to confirm the change. To keep the transaction scope as small as possible, we should split this service into two separate services, each with its own transactional boundary. Here’s
an example of what our original service might look like:

@Service
@Transactional
public class UserService {

@Autowired
private UserRepository userRepo;

@Autowired
private EmailService emailService;

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.");
}
}
To keep the transaction scope as small as possible, we can split our UserService into two separate services, each with its own transactional boundary. Here's an example of what our updated services might look like:

@Service
@Transactional
public class UserService {

@Autowired
private UserRepository userRepo;

public void updateUserEmail(Long userId, String newEmail) {


User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new);
user.setEmail(newEmail);
userRepo.save(user);
}
}

@Service
@Transactional
public class EmailService {

public void sendEmail(String email, String message) {


// send email code
}
}
Avoid Long running Transactions
Long-running transactions can cause performance issues and tie up database resources, so it’s best to keep transactions as short as possible. Let’s say we have a service that processes a large batch of data and updates the database for
each item in the batch. If we execute all of these updates in a single transaction, it could result in a long-running transaction, which can cause performance issues and tie up database resources. To avoid long-running transactions, we can
split our batch processing into smaller chunks and execute each chunk within its own transactional boundary. Here’s an example of what our updated service might look like:

@Service
public class BatchService {

@Autowired
private BatchRepository batchRepo;

public void processBatch(List<Item> items) {


int chunkSize = 1000; // process 1000 items at a time
for (int i = 0; i < items.size(); i += chunkSize) {
List<Item> chunk = items.subList(i, Math.min(i + chunkSize, items.size()));
processChunk(chunk);
}
}

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

Use Propagation parameter judiciously


The Propagation parameter of the @Transactional annotation determines how the transaction propagates to other methods. It's important to use this parameter judiciously and understand how it affects transactional behavior.
Let’s say we have a service that retrieves a user from the database, updates the user’s email address, and then saves the updated user to the database. We also have a separate service that sends a confirmation email to the user.
The updateUserEmail method of our UserService calls the sendEmail method of our EmailService.

@Service
@Transactional
public class UserService {

@Autowired
private UserRepository userRepo;

@Autowired
private EmailService emailService;

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
@Transactional
public class EmailService {

public void sendEmail(String email, String message) {


// send email code
}
}
In this example, both the UserService and EmailService are annotated with @Transactional, which means that each method will be executed within its own transactional boundary. If an exception is thrown within
the updateUserEmail method, the entire transaction will be rolled back, including any updates made by the sendEmail method.

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.

Catch and handle Exceptions


When an exception occurs within a transaction, it’s important to catch and handle it appropriately. Failing to handle exceptions correctly can cause the transaction to fail and leave the database in an inconsistent state.
Let’s say we have a service that transfers money from one account to another. The transferMoney method of our AccountService retrieves the source and destination accounts, updates their balances, and saves the updated accounts
to the database.

@Service
@Transactional
public class AccountService {

@Autowired
private AccountRepository accountRepo;

public void transferMoney(Long sourceAccountId, Long destinationAccountId, BigDecimal amount) {


Account sourceAccount = accountRepository.findById(sourceAccountId).orElseThrow(EntityNotFoundException::new);
Account destinationAccount = accountRepository.findById(destinationAccountId).orElseThrow(EntityNotFoundException::new);
sourceAccount.setBalance(sourceAccount.getBalance().subtract(amount));
destinationAccount.setBalance(destinationAccount.getBalance().add(amount));
accountRepo.save(sourceAccount);
accountRepo.save(destinationAccount);
}
}
In this example, if an exception is thrown within the transferMoney method (such as if one of the accounts cannot be found), the entire transaction will be rolled back, and the balances of both accounts will remain unchanged.
To handle exceptions correctly, we can catch the exception and handle it appropriately. For example, if one of the accounts cannot be found, we can throw a custom exception that provides more information about the error.

@Service
@Transactional
public class AccountService {

@Autowired
private AccountRepository accountRepo;

public void transferMoney(Long sourceAccountId, Long destinationAccountId, BigDecimal amount) {


Account sourceAccount = accountRepository.findById(sourceAccountId).orElseThrow(() -> new AccountNotFoundException("Source account not found."));
Account destinationAccount = accountRepository.findById(destinationAccountId).orElseThrow(() -> new AccountNotFoundException("Destination account not found."));
sourceAccount.setBalance(sourceAccount.getBalance().subtract(amount));
destinationAccount.setBalance(destinationAccount.getBalance().add(amount));
accountRepo.save(sourceAccount);
accountRepo.save(destinationAccount);
}
}

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.

Use a single EntityManager or SessionFactory


It's recommended to use a single EntityManager or SessionFactory for each transaction to ensure consistency and avoid deadlocks. Let’s say we have a service that updates a user’s email address and retrieves their order history
from the database. The updateUserEmail method of our UserService updates the user's email address using the userRepository, and the getUserOrders method retrieves the user's order history using the orderRepository.

@Service
@Transactional
public class UserService {

@Autowired
private UserRepository userRepo;

@Autowired
private OrderRepository orderRepo;

public void updateUserEmail(Long userId, String newEmail) {


User user = userRepo.findById(userId).orElseThrow(EntityNotFoundException::new);
user.setEmail(newEmail);
userRepo.save(user);
}

public List<Order> getUserOrders(Long userId) {


User user = orderRepo.findById(userId).orElseThrow(EntityNotFoundException::new);
return orderRepo.findByUser(user);
}
}

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;

public void updateUserEmail(Long userId, String newEmail) {


User user = em.find(User.class, userId);
user.setEmail(newEmail);
em.merge(user);
}

public List<Order> getUserOrders(Long userId) {


User user = em.find(User.class, userId);
TypedQuery<Order> query = em.createQuery("select o from Order o where o.user = :user", Order.class);
query.setParameter("user", user);
return query.getResultList();
}
}
In this updated example, we’re injecting the EntityManager directly into our UserService. This ensures that both methods within our service are using the same EntityManager instance, which reduces the potential for data
inconsistencies and deadlocks.

Use Optimistic Locking


Optimistic locking can help prevent data conflicts by allowing concurrent transactions to operate on the same data without locking the entire data set. When using optimistic locking, each entity has a version number, and when an update
is performed, the version number is checked to ensure that no other transaction has updated the entity since it was retrieved.
Let’s say we have a service that updates a user’s email address and retrieves their order history from the database. We want to prevent concurrent updates to the user’s email address by multiple transactions.

@Service
@Transactional
public class UserService {

@Autowired
private UserRepository userRepo;

public void updateUserEmail(Long userId, String newEmail) {


User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new);
user.setEmail(newEmail);
userRepo.save(user);
}
}
In this example, if two transactions attempt to update the user’s email address at the same time, the second transaction will overwrite the changes made by the first transaction, potentially causing data inconsistencies.
To use optimistic locking, we can add a version field to our User entity and annotate it with @Version. This tells Spring Data JPA to use optimistic locking for updates to the User entity.

@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;

public void updateUserEmail(Long userId, String newEmail) {


User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new);
user.setEmail(newEmail);
try {
userRepo.save(user);
} catch (ObjectOptimisticLockingFailureException e) {
throw new OptimisticLockingException("User has been updated by another transaction.", e);
}
}
}
In this updated example, we’ve added a try-catch block around the userRepository.save method. If the version number of the User entity has changed since it was retrieved,
an ObjectOptimisticLockingFailureException will be thrown. We catch this exception and throw a custom OptimisticLockingException that provides more information about the error.

Use Read-only Transactions for Read Operations


For read-only operations that don’t modify the database, it’s best to use read-only transactions to improve performance. Read-only transactions do not acquire any database locks, and they can be executed more efficiently than read-write
transactions.
Let’s say we have a service that retrieves a user’s order history from the database. The getUserOrders method of our UserService retrieves the user's order history using the orderRepository.

@Service
@Transactional
public class UserService {

@Autowired
private OrderRepository orderRepo;

public List<Order> getUserOrders(Long userId) {


User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new);
return orderRepo.findByUser(user);
}
}
To use a read-only transaction, we can add the readOnly attribute to the @Transactional annotation. This tells Spring Data JPA that we’re only performing read operations within this transaction, and it can optimize the transaction
accordingly.

@Service
@Transactional(readOnly = true)
public class UserService {

@Autowired
private OrderRepository orderRepository;

public List<Order> getUserOrders(Long userId) {


User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new);
return orderRepository.findByUser(user);
}
}
Use the @Modifying annotation for Update and Delete Operations
When using update or delete queries, use the @Modifying annotation to indicate to Spring that the query is modifying the database.
Let’s say we have a service that updates a user’s email address in the database. The updateUserEmail method of our UserService updates the user's email address using the userRepository.

@Service
@Transactional
public class UserService {

@Autowired
private UserRepository userRepository;

public void updateUserEmail(Long userId, String newEmail) {


User user = userRepository.findById(userId).orElseThrow(EntityNotFoundException::new);
user.setEmail(newEmail);
userRepository.save(user);
}
}
To use the @Modifying annotation, we can add a custom update query to our UserRepository interface using the @Query annotation.

public interface UserRepository extends JpaRepository<User, Long> {

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

You might also like