DEV Community

Cover image for Understanding @Transactional in Spring Boot
Md. Monowarul Amin
Md. Monowarul Amin

Posted on

Understanding @Transactional in Spring Boot

Understanding @Transactional in Spring Boot

Table of Contents

  1. What is @Transactional?
  2. The Phone Call Analogy
  3. Why Do We Need It?
  4. How Hibernate Sessions Work
  5. Common Problems Without @Transactional
  6. Real-World Example
  7. Best Practices
  8. Advanced Concepts

What is @Transactional?

@Transactional is a Spring annotation that manages database transactions automatically. It ensures that:

  1. A database connection stays open for the duration of the method
  2. Multiple database operations are grouped together (all succeed or all fail)
  3. Lazy-loaded data can be accessed within the transaction boundary

Basic Syntax

@Transactional
public void someMethod() {
    // Database operations here
    // Transaction stays open until method completes
}
Enter fullscreen mode Exit fullscreen mode

The Phone Call Analogy

Imagine your application needs to talk to a database. Think of it like making a phone call:

Without @Transactional (Short Call)

┌─────────────┐                    ┌──────────────┐
│   Service   │                    │   Database   │
└─────────────┘                    └──────────────┘
      │                                    │
      │  "Save this rule"                 │
      │───────────────────────────────────>│
      │                                    │
      │  "Done!"                          │
      │<───────────────────────────────────│
      │                                    │
      │  *hangs up*                       │
      │    ❌ Phone disconnected           │
      │                                    │

┌─────────────┐
│ Controller  │
└─────────────┘
      │
      │  "What were the details?"
      │──────────> Service
      │
      │  "Let me check..."
      │  💥 Error! Can't call database - phone is disconnected!
Enter fullscreen mode Exit fullscreen mode

With @Transactional (Extended Call)

┌─────────────┐                    ┌──────────────┐
│   Service   │                    │   Database   │
└─────────────┘                    └──────────────┘
      │                                    │
      │  *picks up phone*                 │
      │  @Transactional starts            │
      │                                    │
      │  "Save this rule"                 │
      │───────────────────────────────────>│
      │                                    │
      │  "Done!"                          │
      │<───────────────────────────────────│
      │                                    │
      │  *keeps phone connected*          │
      │    ✓ Still on call                 │
      │                                    │

┌─────────────┐
│ Controller  │
└─────────────┘
      │
      │  "What were the details?"
      │──────────> Service
      │
      │  "Database, send the details"
      │───────────────────────────────────>│
      │                                    │
      │  *sends details*                  │
      │<───────────────────────────────────│
      │                                    │
      │  *hangs up*                       │
      │  @Transactional ends              │
Enter fullscreen mode Exit fullscreen mode

Why Do We Need It?

Problem 1: LazyInitializationException

This is the most common problem when @Transactional is missing.

@Entity
public class Rule {
    @ElementCollection(fetch = FetchType.LAZY)  // ← Not loaded immediately
    private Set<ServiceDefinition> serviceDefinitions;
}
Enter fullscreen mode Exit fullscreen mode

Without @Transactional:

public Rule create(RuleRequestDTO dto) {
    Rule rule = new Rule();
    return repository.save(rule);  
    // ← Session closes here!
}

// Later in controller:
rule.getServiceDefinitions();  // 💥 LazyInitializationException!
Enter fullscreen mode Exit fullscreen mode

With @Transactional:

@Transactional  // ← Session stays open
public Rule create(RuleRequestDTO dto) {
    Rule rule = new Rule();
    return repository.save(rule);
}

// Later in controller:
rule.getServiceDefinitions();  // ✓ Works! Session is still open
Enter fullscreen mode Exit fullscreen mode

Problem 2: Multiple Operations Need to be Atomic

@Transactional  // ← All or nothing!
public void transferMoney(Account from, Account to, double amount) {
    from.withdraw(amount);  // Operation 1
    to.deposit(amount);     // Operation 2

    // If Operation 2 fails, Operation 1 is rolled back automatically
}
Enter fullscreen mode Exit fullscreen mode

Without @Transactional, if Operation 2 fails:

  • Money is withdrawn from from account ✓
  • Money is NOT deposited to to account ❌
  • Money disappears! 💸

Problem 3: Data Consistency

@Transactional
public void updateOrderStatus(Long orderId) {
    Order order = orderRepository.findById(orderId);
    order.setStatus("PROCESSING");

    // No one else can modify this order until transaction completes
    // Ensures data consistency

    orderRepository.save(order);
}
Enter fullscreen mode Exit fullscreen mode

How Hibernate Sessions Work

The Lifecycle of an Entity

┌──────────────────────────────────────────────────────────┐
│                    Entity Lifecycle                      │
└──────────────────────────────────────────────────────────┘

1. TRANSIENT (New)
   ↓
   Rule rule = new Rule();  // Just created, not in database

2. PERSISTENT (Managed)
   ↓
   repository.save(rule);  // Now tracked by Hibernate
   │
   ├─ Hibernate Session is OPEN
   ├─ Can access lazy collections
   ├─ Changes are automatically saved
   │

3. DETACHED (Disconnected)
   ↓
   // @Transactional method ends
   // Session CLOSES
   │
   ├─ Entity still exists in memory
   ├─ But NOT connected to database
   ├─ ❌ Cannot access lazy collections
   ├─ Changes are NOT automatically saved
Enter fullscreen mode Exit fullscreen mode

Visualizing the Session

// Without @Transactional
public Rule save(Rule rule) {
    ┌─────────────────────────────┐
      Hibernate Session OPENS       repository.save() opens session
    └─────────────────────────────┘

    Rule saved = repository.save(rule);

    ┌─────────────────────────────┐
      Hibernate Session CLOSES      save() method ends
    └─────────────────────────────┘

    return saved;  // ← Entity is now DETACHED
}

// With @Transactional
@Transactional
public Rule save(Rule rule) {
    ┌─────────────────────────────┐
      Hibernate Session OPENS       @Transactional opens session
    └─────────────────────────────┘

    Rule saved = repository.save(rule);

    // Session stays OPEN

    return saved;  // ← Entity is still MANAGED

    ┌─────────────────────────────┐
      Hibernate Session CLOSES      After caller finishes
    └─────────────────────────────┘
}
Enter fullscreen mode Exit fullscreen mode

Common Problems Without @Transactional

Problem 1: LazyInitializationException

Scenario:

// Entity
@Entity
public class Rule {
    @ElementCollection(fetch = FetchType.LAZY)
    private Set<ServiceDefinition> serviceDefinitions;
}

// Service (No @Transactional)
public Rule create(RuleRequestDTO dto) {
    Rule rule = RuleConverter.toEntity(dto);
    return repository.save(rule);  // Session closes here
}

// Controller
public ResponseEntity<?> createRule(RuleRequestDTO dto) {
    Rule rule = service.create(dto);  // rule is detached

    // Trying to access lazy collection
    RuleResponseDTO response = RuleConverter.toResponseDTO(rule);
    // ↑ This calls rule.getServiceDefinitions()
    // 💥 org.hibernate.LazyInitializationException!
}
Enter fullscreen mode Exit fullscreen mode

Error Message:

org.hibernate.LazyInitializationException: 
failed to lazily initialize a collection of role: 
com.example.Rule.serviceDefinitions, 
could not initialize proxy - no Session
Enter fullscreen mode Exit fullscreen mode

Solution:

@Transactional  // ← Add this!
public Rule create(RuleRequestDTO dto) {
    Rule rule = RuleConverter.toEntity(dto);
    return repository.save(rule);
}
Enter fullscreen mode Exit fullscreen mode

Problem 2: Data Not Persisted

Scenario:

// Without @Transactional
public void updateRule(Long id, String newName) {
    Rule rule = repository.findById(id).get();
    rule.setName(newName);
    // Where's the save()?
    // In JPA, if entity is MANAGED, changes auto-save at transaction end
    // But without @Transactional, there's no transaction!
}
Enter fullscreen mode Exit fullscreen mode

Result: Changes are lost! 💾❌

Solution:

@Transactional  // ← Now changes are automatically saved
public void updateRule(Long id, String newName) {
    Rule rule = repository.findById(id).get();
    rule.setName(newName);
    // Automatic save at transaction end!
}
Enter fullscreen mode Exit fullscreen mode

Problem 3: Inconsistent Data

Scenario:

// Without @Transactional
public void processOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).get();
    order.setStatus("PROCESSING");
    orderRepository.save(order);

    // If this fails ↓
    paymentService.charge(order.getAmount());

    // Order status is still "PROCESSING" even though payment failed!
}
Enter fullscreen mode Exit fullscreen mode

Solution:

@Transactional  // ← All or nothing!
public void processOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).get();
    order.setStatus("PROCESSING");
    orderRepository.save(order);

    paymentService.charge(order.getAmount());
    // If this fails, EVERYTHING rolls back!
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example

Let's trace through your exact scenario:

Your Code Structure

// Entity
@Entity
public class Rule {
    @Id
    @GeneratedValue
    private Long id;

    @ElementCollection(fetch = FetchType.LAZY)  // ← LAZY!
    private Set<ServiceDefinition> serviceDefinitions;
}

// Service
public class RuleService {
    public Rule create(RuleRequestDTO dto) {
        Rule rule = RuleConverter.toEntity(dto);
        return ruleRepository.save(rule);
    }
}

// Controller
@PostMapping("/rules")
public ResponseEntity<?> createRule(@RequestBody RuleRequestDTO dto) {
    Rule rule = ruleService.create(dto);  // ← Rule is returned

    // Convert to response DTO
    RuleResponseDTO response = RuleConverter.toResponseDTO(rule);

    return ResponseEntity.ok(response);
}

// Converter
public class RuleConverter {
    public static RuleResponseDTO toResponseDTO(Rule rule) {
        RuleResponseDTO dto = new RuleResponseDTO();
        dto.setId(rule.getId());
        dto.setName(rule.getName());

        // Accessing lazy collection!
        dto.setServiceDefinitions(rule.getServiceDefinitions());  // 💥 Error!

        return dto;
    }
}
Enter fullscreen mode Exit fullscreen mode

Execution Flow Without @Transactional

Step 1: Controller calls service.create()
   ↓
Step 2: Service creates entity and calls repository.save()
   ↓
   ┌──────────────────────────────────────┐
   │  Hibernate Session OPENS             │
   │  - Inserts rule into database        │
   │  - Generates ID                      │
   │  - Rule saved successfully ✓         │
   └──────────────────────────────────────┘
   ↓
   ┌──────────────────────────────────────┐
   │  Hibernate Session CLOSES            │
   │  - Rule becomes DETACHED             │
   └──────────────────────────────────────┘
   ↓
Step 3: Service returns rule to controller
   ↓
Step 4: Controller calls RuleConverter.toResponseDTO(rule)
   ↓
Step 5: Converter tries to access rule.getServiceDefinitions()
   ↓
   ┌──────────────────────────────────────┐
   │  💥 LazyInitializationException      │
   │                                      │
   │  "Session is closed!"                │
   │  "Cannot load lazy collection!"      │
   └──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Execution Flow With @Transactional

Step 1: Controller calls service.create()
   ↓
   ┌──────────────────────────────────────┐
   │  @Transactional starts               │
   │  Hibernate Session OPENS             │
   └──────────────────────────────────────┘
   ↓
Step 2: Service creates entity and calls repository.save()
   ↓
   │  - Inserts rule into database        │
   │  - Generates ID                      │
   │  - Rule saved successfully ✓         │
   │  - Session STAYS OPEN                │
   ↓
Step 3: Service returns rule to controller
   │  - Rule is still MANAGED             │
   ↓
Step 4: Controller calls RuleConverter.toResponseDTO(rule)
   ↓
Step 5: Converter accesses rule.getServiceDefinitions()
   ↓
   │  - Session is STILL OPEN ✓           │
   │  - Hibernate loads lazy collection   │
   │  - Returns serviceDefinitions        │
   ↓
Step 6: Response is built
   ↓
   ┌──────────────────────────────────────┐
   │  @Transactional ends                 │
   │  Hibernate Session CLOSES            │
   └──────────────────────────────────────┘
   ↓
   ✓ Success!
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Place @Transactional on Service Layer

// ✓ GOOD - Service layer
@Service
public class RuleService {
    @Transactional
    public Rule create(RuleRequestDTO dto) {
        // Business logic here
    }
}

// ❌ BAD - Controller layer
@RestController
public class RuleController {
    @Transactional  // Don't do this!
    public ResponseEntity<?> createRule() {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Why? Service layer contains business logic. Controllers should be thin.

2. Read-Only Transactions for Query Methods

@Transactional(readOnly = true)  // ← Optimizes performance
public List<Rule> findAll() {
    return repository.findAll();
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Better performance
  • Prevents accidental modifications
  • Database can optimize read-only queries

3. Specify Rollback Conditions

@Transactional(rollbackFor = Exception.class)  // Roll back on any exception
public void processPayment() {
    // If ANY exception occurs, roll back
}

@Transactional(noRollbackFor = CustomException.class)  // Don't roll back for this
public void logActivity() {
    // Even if CustomException occurs, keep the transaction
}
Enter fullscreen mode Exit fullscreen mode

4. Convert to DTO Inside Transaction

// ✓ BEST PRACTICE
@Transactional
public RuleResponseDTO create(RuleRequestDTO dto) {
    Rule rule = RuleConverter.toEntity(dto);
    Rule saved = repository.save(rule);
    return RuleConverter.toResponseDTO(saved);  // Convert here!
}

// ❌ RISKY
@Transactional
public Rule create(RuleRequestDTO dto) {
    Rule rule = RuleConverter.toEntity(dto);
    return repository.save(rule);  // Return entity - can cause lazy loading issues
}
Enter fullscreen mode Exit fullscreen mode

5. Be Careful with Transaction Propagation

@Transactional  // ← Creates new transaction
public void methodA() {
    methodB();  // Uses same transaction by default
}

@Transactional(propagation = Propagation.REQUIRES_NEW)  // ← Forces new transaction
public void methodB() {
    // Runs in separate transaction
    // If this fails, methodA is not affected
}
Enter fullscreen mode Exit fullscreen mode

Advanced Concepts

Transaction Isolation Levels

@Transactional(isolation = Isolation.READ_COMMITTED)
public void transferMoney() {
    // Other transactions cannot see uncommitted changes
}
Enter fullscreen mode Exit fullscreen mode
Isolation Level Dirty Read Non-Repeatable Read Phantom Read
READ_UNCOMMITTED
READ_COMMITTED
REPEATABLE_READ
SERIALIZABLE

Transaction Timeout

@Transactional(timeout = 30)  // 30 seconds timeout
public void longRunningOperation() {
    // If this takes > 30 seconds, transaction rolls back
}
Enter fullscreen mode Exit fullscreen mode

Programmatic Transaction Management

@Autowired
private TransactionTemplate transactionTemplate;

public void someMethod() {
    transactionTemplate.execute(status -> {
        // Your transactional code here

        if (somethingWrong) {
            status.setRollbackOnly();  // Manual rollback
        }

        return result;
    });
}
Enter fullscreen mode Exit fullscreen mode

Quick Reference: Common Solutions

Solution 1: Add @Transactional to Service

@Transactional
public Rule create(RuleRequestDTO dto) {
    Rule rule = RuleConverter.toEntity(dto);
    return repository.save(rule);
}
Enter fullscreen mode Exit fullscreen mode

Solution 2: Change LAZY to EAGER

@ElementCollection(fetch = FetchType.EAGER)  // Load immediately
private Set<ServiceDefinition> serviceDefinitions;
Enter fullscreen mode Exit fullscreen mode

Solution 3: Initialize Lazy Collections

public Rule create(RuleRequestDTO dto) {
    Rule rule = RuleConverter.toEntity(dto);
    Rule saved = repository.save(rule);

    // Force lazy loading while session is open
    saved.getServiceDefinitions().size();

    return saved;
}
Enter fullscreen mode Exit fullscreen mode

Solution 4: Use Fetch Join

@Query("SELECT r FROM Rule r LEFT JOIN FETCH r.serviceDefinitions WHERE r.id = :id")
Rule findByIdWithServiceDefinitions(@Param("id") Long id);
Enter fullscreen mode Exit fullscreen mode

Summary

Key Takeaways

  1. @Transactional keeps the database connection open for the duration of the method
  2. Lazy-loaded data needs an open session to be accessed
  3. Without @Transactional, entities become detached after repository operations
  4. Use @Transactional on service layer methods that need database access
  5. Multiple database operations are atomic within a transaction

The Golden Rule

If your method returns an entity with lazy-loaded relationships, and those relationships will be accessed later, use @Transactional!

When to Use @Transactional

✓ Service methods that perform database operations

✓ Methods that access lazy-loaded collections

✓ Operations that need to be atomic (all-or-nothing)

✓ Methods that modify multiple entities

When NOT to Use @Transactional

❌ Controller methods (keep controllers thin)

❌ Simple read operations that don't access lazy data

❌ Methods that don't interact with database


Further Reading


Remember: Think of @Transactional as keeping the phone line to the database open. Without it, you're trying to ask questions after hanging up! 📞

Top comments (0)