Should Microservices Share Transactions? Spring Boot Best Practices
Problem
When I was building a microservices system with Spring Boot, I tried to share a transaction between Service A and Service B. They needed to perform coordinated database operations, and I wanted everything to rollback together if something failed.
org.springframework.transaction.TransactionSystemException: Could not roll back transaction at org.springframework.transaction.support.AbstractPlatformTransactionManager.rollback(AbstractPlatformTransactionManager.java:456)Caused by: java.sql.SQLException: Connection is closed at com.zaxxer.hikari.pool.HikariProxyConnection.rollback(HikariProxyConnection.java:123)I thought I could just annotate my service methods with @Transactional and everything would work. But then I found this discussion on Reddit that changed my perspective completely.
Environment
- Spring Boot 3.2.0
- Java 21
- PostgreSQL 15
- Multiple microservices with separate databases
What happened?
I had two microservices: OrderService and InventoryService. When a customer places an order, I needed to:
- Create the order in OrderService
- Decrement inventory in InventoryService
- Rollback both if either fails
Here was my original (wrong) approach:
@Servicepublic class OrderService { @Autowired private OrderRepository orderRepository;
@Autowired private InventoryServiceClient inventoryClient;
@Transactional public void placeOrder(Order order) { // Save order in OrderDB orderRepository.save(order);
// Call InventoryService to decrement stock (different database!) inventoryClient.decrementStock(order.getItemId(), order.getQuantity());
// If this throws exception, order won't rollback properly // because they're in different databases }}I can explain what I was trying to do:
@TransactionalonplaceOrder()to manage the transaction- Save order to OrderDB
- Call InventoryService via HTTP to decrement stock in InventoryDB
- Expected both operations to rollback together
But this approach has a fundamental flaw: I was trying to span a single transaction across two separate microservices with two separate databases.
The Reddit Discussion That Opened My Eyes
Someone on Reddit asked the exact same question:
“Service A and Service B need to share a transaction but operate on different services”
And the responses were eye-opening:
“you should rethink your approach. you need to decide if you want everything in one tx or in 2 separates”
“not much of a spring boot problem - more a database locking / tx design issue”
This made me realize: I was applying monolithic transaction patterns to a distributed microservices architecture. That’s the wrong approach.
Why Cross-Service Transactions Are Problematic
I dug deeper and found several reasons why this approach fails:
1. Tight Coupling
When Service A and Service B share a transaction, they become tightly coupled. If Service B is down, Service A cannot complete its transaction. This defeats the whole purpose of microservices independence.
2. Performance Degradation
Long-running transactions hold database locks for extended periods. When spanning multiple services, the transaction time increases dramatically, leading to lock contention.
3. Availability Issues
A single transaction spanning multiple services creates a single point of failure. If any service is unavailable, the entire operation fails.
4. Scalability Limitations
You cannot scale services independently when they’re tied together by shared transactions.
5. Two-Phase Commit Complexity
Distributed transactions require two-phase commit (2PC), which adds significant overhead and complexity. The coordinator becomes a bottleneck.
The Right Approach: Distributed Data Consistency Patterns
After my research, I learned that the correct approach is to use patterns designed for distributed systems. Let me show you what I implemented.
Pattern 1: Saga Pattern
The Saga pattern breaks a distributed transaction into a sequence of local transactions, each with a compensating action.
@Servicepublic class OrderSagaOrchestrator {
@Autowired private OrderService orderService;
@Autowired private InventoryServiceClient inventoryClient;
@Autowired private PaymentServiceClient paymentClient;
public void executeOrderSaga(Order order) { try { // Step 1: Create order in PENDING state orderService.createPendingOrder(order);
// Step 2: Reserve inventory inventoryClient.reserveStock(order.getItemId(), order.getQuantity());
// Step 3: Process payment paymentClient.processPayment(order.getPaymentInfo());
// Step 4: Confirm order orderService.confirmOrder(order.getId());
} catch (InventoryException e) { // Compensating action: cancel order orderService.cancelOrder(order.getId()); throw new OrderFailedException("Insufficient inventory");
} catch (PaymentException e) { // Compensating actions: release inventory, cancel order inventoryClient.releaseStock(order.getItemId(), order.getQuantity()); orderService.cancelOrder(order.getId()); throw new OrderFailedException("Payment failed"); } }}I can explain the key parts:
- Each step is a local transaction within a single service
- If any step fails, we execute compensating transactions to undo previous steps
- No distributed locks or two-phase commit needed
- Services remain loosely coupled
Pattern 2: Event-Driven Architecture with Compensation
Instead of direct synchronous calls, I use events to coordinate between services:
@Servicepublic class OrderService {
@Autowired private OrderRepository orderRepository;
@Autowired private EventPublisher eventPublisher;
@Transactional public void placeOrder(Order order) { order.setStatus(OrderStatus.PENDING); orderRepository.save(order);
// Publish event for other services to consume eventPublisher.publish(new OrderCreatedEvent( order.getId(), order.getItemId(), order.getQuantity() )); }
@EventListener @Transactional public void handleInventoryReserved(InventoryReservedEvent event) { Order order = orderRepository.findById(event.getOrderId()) .orElseThrow(() -> new OrderNotFoundException(event.getOrderId())); order.setStatus(OrderStatus.CONFIRMED); orderRepository.save(order); }
@EventListener @Transactional public void handleInventoryFailed(InventoryFailedEvent event) { // Compensation: cancel the order Order order = orderRepository.findById(event.getOrderId()) .orElseThrow(() -> new OrderNotFoundException(event.getOrderId())); order.setStatus(OrderStatus.CANCELLED); orderRepository.save(order); }}@Servicepublic class InventoryService {
@Autowired private InventoryRepository inventoryRepository;
@Autowired private EventPublisher eventPublisher;
@EventListener @Transactional public void handleOrderCreated(OrderCreatedEvent event) { try { InventoryItem item = inventoryRepository .findByItemId(event.getItemId()) .orElseThrow(() -> new ItemNotFoundException(event.getItemId()));
if (item.getStock() < event.getQuantity()) { eventPublisher.publish(new InventoryFailedEvent( event.getOrderId(), "Insufficient stock" )); return; }
item.setStock(item.getStock() - event.getQuantity()); inventoryRepository.save(item);
eventPublisher.publish(new InventoryReservedEvent(event.getOrderId()));
} catch (Exception e) { eventPublisher.publish(new InventoryFailedEvent( event.getOrderId(), e.getMessage() )); } }}This approach provides:
- Loose coupling between services
- Each service manages its own transaction
- Eventually consistent model
- Natural compensation through events
Pattern 3: Outbox Pattern
The Outbox pattern solves the dual-write problem: when you need to both update a database and publish an event atomically.
@Entity@Table(name = "outbox_events")public class OutboxEvent { @Id private UUID id;
private String aggregateType; // "Order" private String aggregateId; // orderId private String eventType; // "OrderCreated" private String payload; // JSON event data
@CreatedDate private Instant createdAt;
// Constructors, getters, setters}@Servicepublic class OrderService {
@Autowired private OrderRepository orderRepository;
@Autowired private OutboxEventRepository outboxRepository;
@Transactional public void placeOrder(Order order) { // Save order order.setStatus(OrderStatus.PENDING); orderRepository.save(order);
// Save event to outbox in the SAME transaction OutboxEvent event = new OutboxEvent(); event.setId(UUID.randomUUID()); event.setAggregateType("Order"); event.setAggregateId(order.getId().toString()); event.setEventType("OrderCreated"); event.setPayload(toJson(order)); event.setCreatedAt(Instant.now());
outboxRepository.save(event);
// Both order and event are committed together // A separate process will read from outbox and publish events }
private String toJson(Order order) { // Serialize order to JSON return String.format( "{\"orderId\":%d,\"itemId\":%d,\"quantity\":%d}", order.getId(), order.getItemId(), order.getQuantity() ); }}A separate background process polls the outbox table and publishes events:
@Servicepublic class OutboxPublisher {
@Autowired private OutboxEventRepository outboxRepository;
@Autowired private EventPublisher eventPublisher;
@Scheduled(fixedDelay = 1000) // Poll every second @Transactional public void publishPendingEvents() { List<OutboxEvent> events = outboxRepository .findByPublishedFalseOrderByCreatedAtAsc();
for (OutboxEvent event : events) { try { eventPublisher.publish(event); event.setPublished(true); outboxRepository.save(event); } catch (Exception e) { log.error("Failed to publish event: {}", event.getId(), e); } } }}This pattern ensures:
- Database update and event publishing are atomic
- No dual-write problem
- Reliable event delivery
- Can use Change Data Capture (CDC) with Debezium instead of polling
Same-Application Transaction Boundaries
Now, there’s one case where sharing transactions is acceptable: when both services are in the SAME Spring Boot application.
If Service A and Service B are in the same monolith (or modular monolith), they can share a transaction:
@Servicepublic class OrderService {
@Autowired private OrderRepository orderRepository;
@Autowired private InventoryService inventoryService;
@Transactional public void placeOrder(Order order) { // Both services share the same database // This works because they're in the same application orderRepository.save(order); inventoryService.decrementStock(order.getItemId(), order.getQuantity()); }}@Servicepublic class InventoryService {
@Autowired private InventoryRepository inventoryRepository;
// Use MANDATORY to ensure it runs within an existing transaction @Transactional(propagation = Propagation.MANDATORY) public void decrementStock(Long itemId, int quantity) { InventoryItem item = inventoryRepository.findById(itemId) .orElseThrow(() -> new ItemNotFoundException(itemId));
if (item.getStock() < quantity) { throw new InsufficientStockException(itemId, quantity); }
item.setStock(item.getStock() - quantity); inventoryRepository.save(item); }}The key point here is using Propagation.MANDATORY - it ensures the method MUST run within an existing transaction, rather than creating its own.
Do NOT use REQUIRES_NEW in this case:
// WRONG: This creates a new transaction and suspends the outer one@Transactional(propagation = Propagation.REQUIRES_NEW)public void decrementStock(Long itemId, int quantity) { // If this succeeds but outer transaction fails, // this change won't rollback!}Decision Framework: Choosing the Right Pattern
I created this decision table to help choose the right approach:
┌─────────────────────────────────────┬──────────────────────────────────────────┐│ Scenario │ Recommended Pattern │├─────────────────────────────────────┼──────────────────────────────────────────┤│ Same application, same database │ Single @Transactional ││ Different services, same application │ Single @Transactional with MANDATORY ││ Different applications, same database │ Saga with compensation events ││ Different databases │ Saga + Outbox pattern ││ High consistency requirement │ Saga orchestration ││ High availability requirement │ Event-driven + eventual consistency ││ Need audit trail │ Event Sourcing + Outbox │└─────────────────────────────────────┴──────────────────────────────────────────┘The Reason
I think the fundamental reason developers try to share transactions across microservices is because we’re used to ACID guarantees from monolithic applications. But distributed systems require different thinking:
-
ACID vs BASE: Traditional transactions are ACID (Atomicity, Consistency, Isolation, Durability). Distributed systems are BASE (Basically Available, Soft state, Eventually consistent).
-
CAP Theorem: You can only have two of: Consistency, Availability, Partition tolerance. Microservices typically choose availability over strong consistency.
-
Database-per-Service: Each microservice should own its data. Sharing a database between services defeats the purpose of microservices.
The Reddit commenter was right: “you need to decide if you want everything in one tx or in 2 separates”. This decision shapes your entire architecture.
Summary
In this post, I showed why microservices should NOT share database transactions and demonstrated the correct patterns for distributed data consistency:
- Saga Pattern: Break transactions into sequences with compensation
- Event-Driven Architecture: Use events to coordinate services
- Outbox Pattern: Solve the dual-write problem atomically
The key insight is that microservices architecture fundamentally changes how we think about data consistency. Instead of ACID transactions spanning services, we must think in terms of eventual consistency, compensation, and business-level consistency guarantees.
Remember: If you find yourself trying to share transactions between microservices, take a step back. You’re probably applying monolithic patterns to a distributed system. Choose patterns designed for the problem you’re solving.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Framework @Transactional Documentation
- 👨💻 Saga Pattern - Microservices.io
- 👨💻 Designing Data-Intensive Applications by Martin Kleppmann
- 👨💻 Debezium - Change Data Capture
- 👨💻 Spring Cloud Stream Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments