DEV Community

Thellu
Thellu

Posted on

How to Manage Transactions in Asynchronous Threads in Java (Spring)

A practical guide to handling transactions correctly in async batch operations with database updates and remote service calls.


📌 Introduction

In many enterprise systems, we often deal with batch operations executed in parallel threads, such as:

  • Importing records and updating the database
  • Sending notifications after saving data
  • Interacting with external APIs after writing business-critical information to a local DB

In such cases, one key concern is:

How do we ensure each thread runs inside a proper transaction without affecting others?

This article shows how to design each thread to handle its own transaction independently, ensuring that failures in one thread do not impact the rest.


🔍 Problem Statement

Imagine this logic:

  1. Update database (local transaction)
  2. Call remote service (external dependency)

We want the transaction behavior to be:

  • If any step fails, rollback only the current thread’s transaction
  • Other threads should not be affected
  • We must not accidentally share a single transaction context across threads

Incorrect Approach: Async Inside Transactional Method

@Service
public class BatchService {

    @Transactional
    public void processBatch(List<Item> items) {
        items.forEach(item -> {
            // Bad: Spawning async work inside a transactional method
            CompletableFuture.runAsync(() -> {
                updateDb(item); // Won't participate in the outer transaction
                callRemoteService(item);
            });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

❌ This doesn't work as expected: @Transactional does not propagate into a new thread.

The DB update may execute outside of the transaction context!


✅ Correct Design: Transaction Per Thread

Use an explicitly scoped transactional method called inside each thread, not around it.

@Service
public class BatchService {

    @Autowired
    private ItemService itemService;

    public void processBatch(List<Item> items) {
        items.forEach(item -> {
            CompletableFuture.runAsync(() -> {
                try {
                    itemService.processOneItem(item); // Each thread has its own transaction
                } catch (Exception e) {
                    // Log and continue; only this thread's TX rolls back
                }
            });
        });
    }
}

@Service
public class ItemService {

    @Transactional
    public void processOneItem(Item item) {
        updateDb(item);            // part of transaction
        callRemoteService(item);   // if this fails, TX rolls back
    }
}
Enter fullscreen mode Exit fullscreen mode

Now each thread runs @Transactional logic in processOneItem, and any failure in DB or remote call will trigger rollback for only that item.


⚠️ Bonus Tips

  • Don't use @Transactional on private methods (Spring AOP won't proxy them)
  • If you're using thread pools (ExecutorService), make sure each task independently calls a public service method annotated with @Transactional
  • Log failures but don't let them crash the whole batch

Alternative: Using TransactionTemplate in the Same Class

In some cases, you may want to keep the logic in the same class (e.g., no split between BatchService and ItemService). Since @Transactional won’t work properly in self-invocation (Spring AOP won't trigger), you can use TransactionTemplate programmatically:

@Service
public class BatchService {

    @Autowired
    private PlatformTransactionManager transactionManager;

    public void processBatch(List<Item> items) {
        TransactionTemplate template = new TransactionTemplate(transactionManager);

        items.forEach(item -> {
            CompletableFuture.runAsync(() -> {
                try {
                    template.execute(status -> {
                        updateDb(item);
                        callRemoteService(item); // any exception rolls back
                        return null;
                    });
                } catch (Exception e) {
                    // log and continue
                }
            });
        });
    }

    private void updateDb(Item item) {
        // update database
    }

    private void callRemoteService(Item item) {
        // call external API
    }
}
Enter fullscreen mode Exit fullscreen mode

🧩 This approach avoids AOP limitations and gives you full control over each thread’s transaction boundary.

💬 Final Thoughts

Proper transaction management in asynchronous batch operations can prevent data corruption, increase resilience, and make debugging easier.

If you're designing a system with concurrent operations and transactional safety, follow this "transaction-per-thread" model.


🔗 Related Reading

Top comments (1)

Collapse
 
michael_liang_0208 profile image
Michael Liang

Nice post.