Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 65

Declarative Transaction Management with @Transactional

Recommended resources

In our last lesson, you gained fine-grained control over database interactions by writing custom JPQL and native SQL queries with the @Query annotation. You learned to read, project, and even modify data with precision. Now, we'll address a crucial question that arises when you perform multiple database operations in a single business function: what happens if one of them fails?

This lesson introduces you to declarative transaction management in Spring. You will learn how to use the @Transactional annotation to group a sequence of operations into a single, all-or-nothing unit of work. By the end of this lesson, you will be able to ensure your application's data remains consistent and reliable, even in the face of unexpected errors. This is the final and one of the most critical topics in our module on data persistence.

The All-or-Nothing Principle

Imagine a simple bank transfer: you need to debit money from one account and credit it to another. These are two separate database operations (UPDATE statements). If the debit succeeds but the credit fails (perhaps due to a system crash), money has vanished from one account without appearing in the other. The database is now in an inconsistent, corrupted state.

This is where transactions come in. A transaction is a mechanism that guarantees a set of operations follows the ACID properties (Atomicity, Consistency, Isolation, Durability). For our purposes, the most important one to grasp right now is Atomicity.

  • Atomicity: Ensures that all operations within a transaction are treated as a single, indivisible unit. Either all of them succeed, and the transaction is committed, or if any one of them fails, the entire set of changes is rolled back as if nothing ever happened.
This diagram provides a fantastic overview of transaction management concepts. Note points 1 (Atomicity) and 4 (Data Corruption), which illustrate the core problem we're solving.

To understand the problem in a practical Spring context, let's watch a short video that simulates this exact issue.

Java Spring Boot [2024] Part 17: Transactions @Transactional

The "Peachez Programming" channel provides a clear, concise demonstration of why transactions are necessary.

First, watch the introduction from the beginning to understand the bank transfer analogy and the basic function of @Transactional. Then, skip ahead and watch the demonstration from this segment. You'll see how, without a transaction, an insufficient funds error leaves the database in an inconsistent state (one account is credited, but the debit fails), and how this problem is resolved later.


The Solution: Declarative Transactions with @Transactional

Spring makes managing transactions straightforward through declarative transaction management. Instead of manually writing code to begin, commit, or roll back transactions, you simply "declare" that a method should be transactional. The primary way to do this is with the @Transactional annotation.

When you annotate a method (or class) with @Transactional, Spring works its magic behind the scenes. It uses Aspect-Oriented Programming (AOP) to create a proxy around your service bean. This proxy intercepts calls to the annotated method and wraps them in transaction management logic.

Here's a simplified view of what happens:

  1. A transaction is started before your method code executes.
  2. Your method code runs.
  3. If the method completes successfully, the transaction is committed.
  4. If the method throws an unhandled RuntimeException or Error, the transaction is rolled back.

This video from the "Code Decode" channel provides an excellent debugging session that shows this behavior in action. You'll see how, without the annotation, a save() call immediately writes to the database, but with the annotation, the write is deferred until the very end of the method.

Transaction Management in hibernate in spring boot Interview questions | with Example | Code Decode

This video demonstrates the practical effect of adding @Transactional to a service method.

Watch the segment from this point. The presenter debugs a service method twice: once without @Transactional and once with it, clearly showing the difference in when data is committed to the database.


Key Concepts and Best Practices

Using @Transactional is simple, but understanding its nuances is key to using it effectively.

Where to Place @Transactional

The standard practice is to place @Transactional on public methods in your service layer beans (e.g., classes annotated with @Service). The service layer is responsible for orchestrating business logic, which often involves coordinating multiple repository calls. This is the natural boundary for a transaction.

Pitfall: @Transactional does not work on private methods. Because Spring uses proxies, it can only intercept calls made from outside the object. A call from one method to another private method within the same object (this.myPrivateMethod()) will not be intercepted, and no transaction will be started for the private method. The "Mastering Spring Boot @Transactional" diagram you saw earlier highlights this in point 3.

Rollback Rules

By default, a transaction will only roll back for unchecked exceptions (RuntimeException and its subclasses) and Errors. It will not roll back for checked exceptions (any subclass of Exception that isn't RuntimeException).

You can customize this behavior using the rollbackFor and noRollbackFor attributes. For example, to ensure a transaction rolls back even for a checked SQLException:

@Transactional(rollbackFor = SQLException.class)
public void performDatabaseWork() throws SQLException {
    // ... logic that might throw SQLException
}

This is a common source of confusion, so it's a critical detail to remember. The Baeldung article "Transactions with Spring and JPA" provides a good reference on this.

Transactions with Spring and JPA

This article explains the declarative approach to managing rollbacks.

Please read the section titled Transaction Rollback. Focus on the code examples for the declarative approach using rollbackFor and noRollbackFor.

Read-Only Transactions

For operations that only read data (e.g., a series of SELECT statements) and do not modify it, you can provide a performance hint to the persistence provider by setting the readOnly flag.

@Transactional(readOnly = true)
public UserProfileDTO getUserProfile(Long userId) {
    // ... logic that only fetches data
}

This can lead to optimizations at the JPA and database level, such as preventing dirty checking of entities and setting the underlying database connection to read-only mode.

Transaction Propagation

What happens when a method marked as @Transactional calls another method that is also marked @Transactional? This is controlled by the propagation behavior.

The default propagation level is REQUIRED. This means:

  • If a transaction is already active, the method will join that existing transaction.
  • If no transaction is active, a new one will be created.

This is usually what you want. It ensures that a series of service calls within a single top-level operation all participate in the same "all-or-nothing" transaction.

Another common propagation level is REQUIRES_NEW. This means:

  • The method will always start a new, independent transaction.
  • If an existing transaction is active, it will be suspended while the new transaction executes.

This is useful for tasks that must succeed or fail independently of the calling transaction, such as logging an audit record to the database.

This diagram clearly illustrates the difference between `REQUIRED` and `REQUIRES_NEW` and their rollback behaviors. With `REQUIRED`, an inner exception can cause the whole transaction to roll back. With `REQUIRES_NEW`, only the inner transaction rolls back, allowing the outer transaction to potentially proceed.

Hands-On: Ensuring Atomic Operations

Let's apply this to our group-manager project. We'll create a service method that performs multiple database writes and see how @Transactional protects our data integrity.

Our scenario: We want to create a method that deletes a User. When a User is deleted, all Posts they authored should also be deleted. This must be an atomic operation.

  1. Create a New Service
    In your service package (e.g., com.example.groupmanager.service), create a new class called UserService. Annotate it with @Service.

    package com.example.groupmanager.service;
    
    import com.example.groupmanager.repository.PostRepository;
    import com.example.groupmanager.repository.UserRepository;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    @Service
    public class UserService {
    
        @Autowired
        private UserRepository userRepository;
    
        @Autowired
        private PostRepository postRepository;
    
        // We will implement the methods here
    }
    

    Note: If you already created a UserService in a previous lesson, simply add the new methods to it.

  2. Implement the Unsafe Method
    Add the following method to your UserService. Notice it has no @Transactional annotation yet. We will intentionally throw an exception after deleting the posts to simulate a failure.

    // Inside UserService.java
    public void deleteUserAndPostsUnsafe(Long userId) {
        System.out.println("--- Deleting user and posts (UNSAFE) ---");
    
        // First, delete all posts by this user
        // In a real app with proper foreign key constraints, you'd handle this differently (e.g., cascading deletes),
        // but for this demo, we do it manually.
        postRepository.deleteAll(postRepository.findByUserId(userId));
        System.out.println("Posts for user " + userId + " deleted.");
    
        // Simulate a failure before deleting the user
        if (true) {
            throw new RuntimeException("Simulating a network failure before deleting user!");
        }
    
        // This part will never be reached
        userRepository.deleteById(userId);
        System.out.println("User " + userId + " deleted.");
    }
    

    You'll also need to add the findByUserId method to your PostRepository if it doesn't exist:

    // In PostRepository.java
    List<Post> findByUserId(Long userId);
    
  3. Implement the Transactional Method
    Now, add the safe, transactional version of the method. It's identical, but with the @Transactional annotation.

    // Inside UserService.java
    @Transactional
    public void deleteUserAndPostsSafe(Long userId) {
        System.out.println("--- Deleting user and posts (SAFE & TRANSACTIONAL) ---");
    
        postRepository.deleteAll(postRepository.findByUserId(userId));
        System.out.println("Posts for user " + userId + " deleted.");
    
        // Simulate a failure before deleting the user
        if (true) {
            throw new RuntimeException("Simulating a network failure inside a transaction!");
        }
        
        userRepository.deleteById(userId);
        System.out.println("User " + userId + " deleted.");
    }
    
  4. Update DataLoader.java to Test Both Scenarios
    Let's modify our DataLoader to call these new service methods. We'll need to inject the UserService.

    First, add the service as a field:

    @Autowired private UserService userService;
    

    Then, replace the entire run method with the following code. It sets up data, then attempts the unsafe deletion, and finally (in a commented-out section) the safe one.

    @Override
    public void run(String... args) throws Exception {
        // --- Clean and setup data ---
        postRepository.deleteAll();
        userRepository.deleteAll();
        tagRepository.deleteAll();
    
        User user1 = userRepository.save(new User("Alice", "alice@example.com", "pass1"));
        User user2 = userRepository.save(new User("Bob", "bob@example.com", "pass2"));
    
        Post post1 = new Post("Alice's First Post", "Content.");
        post1.setUser(user1);
        postRepository.save(post1);
    
        Post post2 = new Post("Alice's Second Post", "More content.");
        post2.setUser(user1);
        postRepository.save(post2);
    
        System.out.println("--- Sample Data Loaded ---");
        System.out.println("Initial user count: " + userRepository.count());
        System.out.println("Initial post count: " + postRepository.count());
        System.out.println("Posts for user Alice (ID " + user1.getId() + "): " + postRepository.findByUserId(user1.getId()).size());
    
        // --- Scenario 1: Unsafe Deletion ---
        System.out.println("\nATTEMPTING UNSAFE DELETION of user Alice (ID " + user1.getId() + ")");
        try {
            userService.deleteUserAndPostsUnsafe(user1.getId());
        } catch (Exception e) {
            System.err.println("Caught expected exception: " + e.getMessage());
        }
    
        System.out.println("\n--- State after UNSAFE deletion attempt ---");
        System.out.println("Final user count: " + userRepository.count());
        System.out.println("Final post count: " + postRepository.count());
        userRepository.findById(user1.getId())
                      .ifPresent(u -> System.out.println("User Alice STILL EXISTS in the database."));
        System.out.println("Posts for user Alice: " + postRepository.findByUserId(user1.getId()).size() + " <-- DATA INCONSISTENCY!");
    
        // --- Scenario 2: Safe Deletion ---
        /*
        System.out.println("\nATTEMPTING SAFE DELETION of user Alice (ID " + user1.getId() + ")");
        try {
            userService.deleteUserAndPostsSafe(user1.getId());
        } catch (Exception e) {
            System.err.println("Caught expected exception: " + e.getMessage());
        }
    
        System.out.println("\n--- State after SAFE deletion attempt ---");
        System.out.println("Final user count: " + userRepository.count());
        System.out.println("Final post count: " + postRepository.count());
        System.out.println("Posts for user Alice: " + postRepository.findByUserId(user1.getId()).size() + " <-- DATA IS CONSISTENT!");
        */
    }
    
  5. Run and Observe
    Run the application. The console output for the unsafe scenario will show that Alice's posts were deleted, but Alice herself was not. The database is now inconsistent.

  6. Test the Safe Method
    Now, comment out the "Unsafe Deletion" block in DataLoader.java and uncomment the "Safe Deletion" block. Rerun the application.

    This time, observe the output. Although the exception is still thrown and caught, the postRepository.count() and postRepository.findByUserId(...) calls show that nothing was deleted. The transaction was rolled back, preserving the integrity of your data.


Conclusion

You have now completed the final piece of the Spring Data JPA puzzle. Managing transactions is a fundamental requirement for any robust backend application, and with @Transactional, Spring provides a powerful and elegant way to handle it.

Key Takeaways:

  • A transaction ensures a sequence of operations is atomic (all or nothing).
  • The @Transactional annotation is Spring's primary tool for declarative transaction management.
  • Place @Transactional on public methods in the service layer to define transaction boundaries.
  • By default, transactions roll back on RuntimeExceptions but not on checked Exceptions. Use rollbackFor to customize this.
  • Use @Transactional(readOnly = true) for methods that only fetch data to enable performance optimizations.
  • Propagation rules, like the default REQUIRED, define how nested transactional calls behave.

This lesson concludes our module on Data Persistence. You've come a long way, from basic entity mappings to complex queries and now, robust transaction management. In our next module, we will shift our focus from data to security, exploring how to protect your API with Spring Security and JSON Web Tokens (JWT).