Skip to content

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.

Terminal window
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:

  1. Create the order in OrderService
  2. Decrement inventory in InventoryService
  3. Rollback both if either fails

Here was my original (wrong) approach:

src/main/java/com/example/order/OrderService.java
@Service
public 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:

  • @Transactional on placeOrder() 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.

src/main/java/com/example/order/saga/OrderSagaOrchestrator.java
@Service
public 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:

src/main/java/com/example/order/OrderService.java
@Service
public 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);
}
}
src/main/java/com/example/inventory/InventoryService.java
@Service
public 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.

src/main/java/com/example/outbox/OutboxEvent.java
@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
}
src/main/java/com/example/order/OrderService.java
@Service
public 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:

src/main/java/com/example/outbox/OutboxPublisher.java
@Service
public class OutboxPublisher {
@Autowired
private OutboxEventRepository outboxRepository;
@Autowired
private EventPublisher eventPublisher;
@Scheduled(fixedDelay = 1000) // Poll every second
@Transactional
public void publishPendingEvents() {
List&lt;OutboxEvent&gt; 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:

src/main/java/com/example/order/OrderService.java
@Service
public 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());
}
}
src/main/java/com/example/inventory/InventoryService.java
@Service
public 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:

src/main/java/com/example/inventory/InventoryService.java
// 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:

Transaction
┌─────────────────────────────────────┬──────────────────────────────────────────┐
│ 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:

  1. ACID vs BASE: Traditional transactions are ACID (Atomicity, Consistency, Isolation, Durability). Distributed systems are BASE (Basically Available, Soft state, Eventually consistent).

  2. CAP Theorem: You can only have two of: Consistency, Availability, Partition tolerance. Microservices typically choose availability over strong consistency.

  3. 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:

  1. Saga Pattern: Break transactions into sequences with compensation
  2. Event-Driven Architecture: Use events to coordinate services
  3. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments