Join our FREE personalized newsletter for news, trends, and insights that matter to everyone in America

Newsletter
New

How To Manage Transactions In Asynchronous Threads In Java (spring)

Card image cap

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);  
            });  
        });  
    }  
}  

❌ 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  
    }  
}  

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  
    }  
}  

???? 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