Skip to Content
Course content

139: Transaction Management in JPA

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Transactions are the safety net of any data-driven application. If you're moving money between accounts or updating an order status and its inventory simultaneously, you can't afford for one operation to succeed while the other fails. In JPA, transaction management ensures that a group of operations are treated as a single unit of work: either everything commits, or everything rolls back.

Building a Simple Fund Transfer

Let's build a small service to handle transferring money between two bank accounts. I'll keep the entities lean since we've already covered mapping in previous lessons. We just need an Account entity with a balance.

public class Account {
    @Id
    private Long id;
    private BigDecimal balance;

    // Getters, setters, and a method to deduct/add funds
    public void withdraw(BigDecimal amount) {
        this.balance = this.balance.subtract(amount);
    }

    public void deposit(BigDecimal amount) {
        this.balance = this.balance.add(amount);
    }
}

Now, I'll create a TransferService. My goal here is to take money from one account and give it to another. This is where the transaction becomes critical.

@Service
public class TransferService {
    @Autowired
    private AccountRepository accountRepository;

    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();

        from.withdraw(amount);
        accountRepository.save(from);

        // Imagine something goes wrong here...
        if (amount.compareTo(BigDecimal.valueOf(10000)) > 0) {
            throw new IllegalArgumentException("Transfer limit exceeded!");
        }

        to.deposit(amount);
        accountRepository.save(to);
    }
}

The Mistake: Assuming Automatic Atomicity

If I run this code as-is and trigger that IllegalArgumentException, I'm going to have a bad day. Why? Because without an explicit transaction boundary, each save() call might be treated as its own transaction. The money would leave the first account, the exception would hit, and the second account would never receive the funds. The money essentially vanishes into the ether.

I actually did this in my first professional project. I assumed the framework "just handled it." It doesn't. You have to be explicit about where the unit of work begins and ends.

Fixing it with @Transactional

To fix this, I'll use the @Transactional annotation. This tells the JPA provider (and the underlying transaction manager) to wrap the entire method in a single transaction. If a RuntimeException is thrown, the manager will trigger a rollback, returning the first account's balance to its original state.

@Service
public class TransferService {
    @Autowired
    private AccountRepository accountRepository;

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();

        from.withdraw(amount);
        accountRepository.save(from);

        if (amount.compareTo(BigDecimal.valueOf(10000)) > 0) {
            throw new IllegalArgumentException("Transfer limit exceeded!");
        }

        to.deposit(amount);
        accountRepository.save(to);
    }
}

Now, if that exception fires, JPA ensures that the withdraw operation is never committed to the database. It's all or nothing.

A Quick Warning on Self-Invocation

Here is a nuance that trips up almost everyone. @Transactional works via AOP proxies. When a class calls its own method, it bypasses the proxy. Look at this mistake:

public void executeTransfer(Long from, Long to, BigDecimal amount) {
    // Calling a @Transactional method from within the same class!
    this.transfer(from, to, amount); 
}

@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
    // ... logic ...
}

In the example above, executeTransfer is not transactional. Because it calls transfer using this, the @Transactional annotation on the transfer method is completely ignored. If you need a transactional boundary, the call must come from outside the bean.




📋 Practical Task

Implement a Rollback-Safe Inventory Adjustment System

You are tasked with building a WarehouseService that handles the distribution of a product to multiple store locations. You must ensure that if any single store update fails, no stores receive the stock (atomicity).

Requirements:

  • Create a Product entity (with stockQuantity) and a StoreInventory entity (with currentStock).
  • Implement a method distributeStock(Long productId, Map<Long, Integer> storeAllocations).
  • The method should:
    1. Deduct the total sum of all allocations from the main Product stock.
    2. Iterate through the map and increase the stock for each StoreInventory.
    3. Throw a RuntimeException if any store's currentStock would exceed a maximum capacity of 500 units.
  • Ensure that if the capacity limit is hit for the 5th store in a list of 10, the Product stock is restored and the first 4 stores' updates are rolled back.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.