Transaction Management in Spring

Transaction Management in Spring

Transaction management is essential to ensuring data integrity and consistency in database interactions. Spring Framework provides extensive support for transaction management and helps in maintaining integrity in your database transactions by providing declarative transaction management which can be easily set up without boilerplate code.

What is a Transaction?

A transaction is a sequence of operations performed as a single logical unit of work. A logical unit of work must exhibit four properties, known as the ACID properties: Atomicity, Consistency, Isolation, and Durability.

  • Atomicity - A transaction should be atomic, which means it should either be completed fully or not executed at all.

  • Consistency - The transaction should bring the system from one consistent state to another.

  • Isolation - The intermediate state of a transaction is invisible to other transactions.

  • Durability - Once committed, the effects of the transaction are permanent.

Understanding How Transactions Work - Proxies and More

In Spring, transactions are managed through the concept of proxies. When you annotate a method or class with @Transactional, Spring creates a proxy around the actual bean so that it can add transaction management capabilities to it.

The proxy is responsible for:

  1. Starting a transaction: Before the method is executed, the proxy starts or joins a transaction.

  2. Committing or Rolling Back: After the method is executed, depending on the outcome (success or exception), the proxy either commits the transaction, making all changes to the database permanent or rolls it back, undoing all changes.

  3. Resource Cleanup: The proxy takes care of releasing any resources that were allocated during the transaction.

The fact that Spring uses proxies for transaction management has a few implications:

  • The @Transactional annotation will only have an effect if the method is called on the proxy instance. Calling a method internally within the same class will not go through the proxy, and thus transaction management will not be applied.

  • Since the proxy is created when the bean is instantiated, method-level changes to @Transactional properties at runtime will not have any effect.

  • The proxy mechanism works best with public methods. Using @Transactional on private or protected methods is not recommended as it might not work as expected.

Understanding the proxy mechanism is crucial for effectively working with transactions in Spring. It helps in identifying the scope of transactions and avoiding common pitfalls, such as internal method calls bypassing the proxy, and thus, not being transactional.

Two types of Transaction Management

Spring supports two types of transaction management:

  • Programmatic Transaction Management: This means that you can manage transactions through your code.

  • Declarative Transaction Management: This is the more abstracted way, allowing you to manage transactions directly through configurations.

In this guide, we will focus on declarative transaction management, which is the most common approach.

Setting up Spring Transaction Management

Before you can use transactions, you have to set them up in your project. This involves a few steps.

  1. Add the Spring Data JPA and a database driver as dependencies in your pom.xml file.

  2. Configure your application.properties file with your database information.

  3. Enable transaction management in your Spring configuration. You can do this by simply adding @EnableTransactionManagement to your configuration class.

@Configuration
@EnableTransactionManagement
public class PersistenceConfig {
    // ... other configuration ...
}

Using the @Transactional Annotation

The @Transactional annotation is at the heart of Spring’s declarative transaction management. Simply adding this annotation can make a method run within a transaction context.

Basic Use

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public void createUsers(User user1, User user2) {
        userRepository.save(user1);
        userRepository.save(user2);
    }
}

In this example, if the saving of user1 is successful but saving user2 fails, user1 will not be committed to the database.

Using @Transactional at Class and Method Level

Besides using the @Transactional annotation on methods, it can also be applied at the class level. When @Transactional is used on a class, it indicates that all public methods within the class should be executed within a transaction context.

However, it's important to note that if a method within the class is also annotated with @Transactional, the method-level annotation overrides the class-level annotation settings for that specific method.

@Service
@Transactional
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void createUser(User user) {
        userRepository.save(user);
    }    

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateUser(User user) {
        userRepository.save(user);
    }
}

In this example, the createUser method inherits the transaction configuration from the class-level @Transactional annotation. However, for the updateUser method, the method-level @Transactional annotation is used, which overrides the class-level settings with its own propagation setting.

This capability provides flexibility to apply a general transaction configuration to a set of methods within a class while allowing customization for specific methods that may have unique transaction requirements.

Propagation

Propagation deals with questions like "What should happen if a transactional method is called by another transactional method?" This can be configured using the propagation attribute of the @Transactional annotation.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createUser(User user) {
    userRepository.save(user);
}
  • Propagation.REQUIRED If there is an active transaction, the method will join that transaction. If there isn’t an active transaction, a new one will be created. This is the default value.

  • Propagation.SUPPORTS: The method will use the active transaction if one exists. If there is no active transaction, the method won’t use any transaction.

  • Propagation.MANDATORY: The method must be called within the context of an existing transaction. If there is no active transaction, an exception is thrown.

  • Propagation.REQUIRES_NEW: The method will always run in a new transaction. If there is an active transaction, it will be suspended.

  • Propagation.NOT_SUPPORTED: The method should not be executed within the context of a transaction. If there is an active transaction, it will be suspended during the method execution.

  • Propagation.NEVER: The method should not be executed within the context of a transaction and an exception is thrown if there is an active transaction.

  • Propagation.NESTED: The method will be executed within a nested transaction if there is an active transaction. Otherwise, it behaves like Propagation.REQUIRED. Nested transactions are transactions that can be committed or rolled back independently of the outer transaction.

Isolation

Isolation levels define how transactions separate themselves from each other. It can be set using the isolation attribute of @Transactional.

@Transactional(isolation = Isolation.READ_COMMITTED)
public User findUser(Long id) {
    return userRepository.findById(id);
}
  • Isolation.DEFAULT This leaves the isolation level up to the underlying data source. Essentially, this means using whatever isolation level is default for the database you are using.

  • Isolation.READ_UNCOMMITTED: This isolation level allows transactions to read data that has been modified but not yet committed by other transactions. This is the lowest level of isolation and can lead to issues such as dirty reads.

  • Isolation.READ_COMMITTED: A transaction can only read data that has been committed by other transactions. This isolation level prevents dirty reads but still allows other concurrency issues such as non-repeatable reads. Non-repeatable reads occur when a value read by a transaction is modified by another transaction before the first transaction is complete.

  • Isolation.REPEATABLE_READ: Once a transaction reads data, other transactions can't modify that data. However, other transactions can insert new rows that a transaction may subsequently read. This level prevents dirty and non-repeatable reads but still allows phantom reads. Phantom reads occur when a transaction re-queries for a set of rows that match a certain condition and finds that another committed transaction has inserted additional rows that match the condition.

  • Isolation.SERIALIZABLE: This is the highest level of isolation. Transactions are executed in such a way that only one transaction can access the data at a time. This eliminates any concurrency issues but can negatively impact performance in high-throughput systems.

Read-only

readOnly: Indicates if the transaction is read-only. A read-only transaction does not modify data, it only reads it. This can be used as a performance optimization for some datastores. Default is false.

@Transactional(readOnly = true)
public User getUser(Long id) { ... }

Timeouts and Rollbacks

You can specify a timeout period for a transaction, after which if it is not completed, it is automatically rolled back.

timeout: Specifies the amount of time (in seconds) a transaction can take before being rolled back due to timeout. Default is the default timeout of the transaction system, or none if timeouts are not supported.

@Transactional(timeout = 5)
public void createUser(User user) {
    userRepository.save(user);
}

By default, transactions will be rolled back only on runtime exceptions. You can change this behavior using the rollbackFor and noRollbackFor attributes.

@Transactional(rollbackFor = CustomException.class)
public void createUser(User user) throws CustomException {
    try {
        userRepository.save(user);
    } catch (SomeException ex) {
        throw new CustomException("Failed to create user", ex);
    }
}

rollbackFor: Defines a list of exception classes that must cause the transaction to be rolled back. By default, a transaction will be rolled back on runtime exceptions and not on checked exceptions.

rollbackForClassName: Similar to rollbackFor, but takes the class names of the exceptions as strings.

noRollbackFor: Defines a list of exception classes that must not cause the transaction to be rolled back.

noRollbackForClassName: Similar to noRollbackFor, but takes the class names of the exceptions as strings.

Creating Custom Annotations for Reusability

Sometimes you may find yourself repeatedly using @Transactional with the same set of attributes. Instead of duplicating this configuration across different methods or classes, you can create a custom annotation that encapsulates the common settings.

Here’s how you can create a custom annotation that is equivalent to @Transactional with propagation set to REQUIRES_NEW and isolation set to SERIALIZABLE.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE)
public @interface UserTx{
}

You can then use this custom annotation instead of @Transactional.

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @UserTx
    public void createUser(User user) {
        userRepository.save(user);
    }
}

This approach allows for cleaner and more maintainable code, especially when dealing with complex transaction management configurations.

Programmatic Transaction Management

Although less common, sometimes you may need to manage transactions programmatically. Spring provides a PlatformTransactionManager for this purpose.

@Service
public class UserService {

    private final UserRepository userRepository;
    private final PlatformTransactionManager transactionManager;

    public UserService(UserRepository userRepository, PlatformTransactionManager transactionManager) {
        this.userRepository = userRepository;
        this.transactionManager = transactionManager;
    }

    public void createUser(User user) {
        TransactionDefinition def = new DefaultTransactionDefinition();
        TransactionStatus status = transactionManager.getTransaction(def);
        try {
            userRepository.save(user);
            transactionManager.commit(status);
        } catch (DataAccessException e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
}

In this example, we're manually beginning, committing, or rolling back the transaction using PlatformTransactionManager.

Handling Transactional Data Integrity

Spring allows you to define database constraints and validations to maintain data integrity. For instance, you can use Java Persistence API (JPA) annotations like @Column(nullable = false) to enforce certain constraints.

Moreover, catching data integrity violations is also an essential part of transaction management. Spring provides DataIntegrityViolationException which can be caught and handled appropriately.

Transaction Performance Considerations

When monitoring transactions, it's important to look out for certain performance-related issues:

  1. Long-Running Transactions: These can hold locks for longer than necessary, affecting data availability for other transactions.

  2. Deadlocks: When two or more transactions are waiting for each other to release locks, leading to a standstill.

  3. Transaction Rollbacks: Frequent rollbacks could indicate business logic errors or optimistic locking conflicts.

Monitoring these aspects can be crucial for maintaining the performance and stability of your application, especially in high-throughput scenarios.

Conclusion

In conclusion, managing transactions effectively is a fundamental aspect of building reliable and robust applications, especially when working with databases. Spring Framework provides a powerful and flexible transaction management mechanism through the @Transactional annotation, allowing developers to declaratively control transaction boundaries. We've explored various attributes of the @Transactional annotation, such as isolation levels, propagation behaviours, and customization using custom annotations. It's important to understand the intricacies of these attributes to ensure data integrity and consistency in your application. Moreover, the ability to create custom annotations adds an extra layer of abstraction, enabling cleaner and more maintainable code. Being well-acquainted with transaction management concepts and tools provided by Spring will undoubtedly be an asset in your journey as a software developer.

Read more

For further information on transaction management, please refer to the official Spring Docs.

Did you find this article valuable?

Support Jarosław Samulewski by becoming a sponsor. Any amount is appreciated!