Back to Journal
System Design

How to Build Saga Pattern Implementation Using Spring Boot

Step-by-step tutorial for building Saga Pattern Implementation with Spring Boot, from project setup through deployment.

Muneer Puthiya Purayil 22 min read

This tutorial builds a complete saga orchestration system in Spring Boot — from project setup through JPA persistence, step execution with Spring's dependency injection, compensation handling, and REST endpoints for triggering and monitoring sagas. By the end, you will have a reusable saga framework and a working order fulfillment saga.

Project Setup

Generate a Spring Boot project with the required dependencies:

bash
1curl https://start.spring.io/starter.zip \
2 -d dependencies=web,data-jpa,postgresql,validation,actuator \
3 -d type=maven-project \
4 -d javaVersion=17 \
5 -d name=saga-demo \
6 -o saga-demo.zip
7unzip saga-demo.zip && cd saga-demo
8 

Add the UUID generator dependency to pom.xml:

xml
1<dependency>
2 <groupId>com.fasterxml.uuid</groupId>
3 <artifactId>java-uuid-generator</artifactId>
4 <version>4.3.0</version>
5</dependency>
6 

Configure the database in application.yml:

yaml
1spring:
2 datasource:
3 url: jdbc:postgresql://localhost:5432/saga_demo
4 username: postgres
5 password: postgres
6 jpa:
7 hibernate:
8 ddl-auto: validate
9 properties:
10 hibernate:
11 dialect: org.hibernate.dialect.PostgreSQLDialect
12 open-in-view: false
13 

Step 1: Database Migration

Create the schema with Flyway or manually:

sql
1-- V1__create_saga_tables.sql
2CREATE TABLE saga_state (
3 id UUID PRIMARY KEY,
4 saga_type VARCHAR(100) NOT NULL,
5 status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
6 current_step INT NOT NULL DEFAULT 0,
7 context JSONB NOT NULL DEFAULT '{}',
8 completed_steps TEXT[] NOT NULL DEFAULT '{}',
9 error TEXT,
10 version INT NOT NULL DEFAULT 0,
11 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
13);
14 
15CREATE INDEX idx_saga_state_status ON saga_state(status);
16CREATE INDEX idx_saga_state_type ON saga_state(saga_type);
17CREATE INDEX idx_saga_state_created ON saga_state(created_at);
18 

Step 2: JPA Entity

java
1package com.example.saga.entity;
2 
3import jakarta.persistence.*;
4import org.hibernate.annotations.JdbcTypeCode;
5import org.hibernate.type.SqlTypes;
6 
7import java.time.Instant;
8import java.util.ArrayList;
9import java.util.List;
10import java.util.Map;
11import java.util.UUID;
12 
13@Entity
14@Table(name = "saga_state")
15public class SagaStateEntity {
16 
17 @Id
18 private UUID id;
19 
20 @Column(name = "saga_type", nullable = false)
21 private String sagaType;
22 
23 @Column(nullable = false)
24 @Enumerated(EnumType.STRING)
25 private SagaStatus status = SagaStatus.RUNNING;
26 
27 @Column(name = "current_step", nullable = false)
28 private int currentStep = 0;
29 
30 @JdbcTypeCode(SqlTypes.JSON)
31 @Column(columnDefinition = "jsonb", nullable = false)
32 private Map<String, Object> context;
33 
34 @Column(name = "completed_steps", columnDefinition = "text[]")
35 private String[] completedSteps = new String[0];
36 
37 @Column
38 private String error;
39 
40 @Version
41 private int version;
42 
43 @Column(name = "created_at", nullable = false, updatable = false)
44 private Instant createdAt = Instant.now();
45 
46 @Column(name = "updated_at", nullable = false)
47 private Instant updatedAt = Instant.now();
48 
49 public static SagaStateEntity create(String sagaType, Map<String, Object> context) {
50 var entity = new SagaStateEntity();
51 entity.id = UUID.randomUUID();
52 entity.sagaType = sagaType;
53 entity.context = context;
54 return entity;
55 }
56 
57 public void addCompletedStep(String stepName) {
58 var steps = new ArrayList<>(List.of(this.completedSteps));
59 steps.add(stepName);
60 this.completedSteps = steps.toArray(new String[0]);
61 this.currentStep++;
62 this.updatedAt = Instant.now();
63 }
64 
65 // Getters and setters
66 public UUID getId() { return id; }
67 public String getSagaType() { return sagaType; }
68 public SagaStatus getStatus() { return status; }
69 public void setStatus(SagaStatus status) { this.status = status; this.updatedAt = Instant.now(); }
70 public int getCurrentStep() { return currentStep; }
71 public Map<String, Object> getContext() { return context; }
72 public void setContext(Map<String, Object> context) { this.context = context; }
73 public String[] getCompletedSteps() { return completedSteps; }
74 public String getError() { return error; }
75 public void setError(String error) { this.error = error; }
76 public int getVersion() { return version; }
77 public Instant getCreatedAt() { return createdAt; }
78 public Instant getUpdatedAt() { return updatedAt; }
79}
80 
81enum SagaStatus {
82 RUNNING, COMPENSATING, COMPLETED, FAILED
83}
84 

Step 3: Step Interface and Retry Policy

java
1package com.example.saga;
2 
3import java.time.Duration;
4 
5public interface SagaStep<T> {
6 String getName();
7 void execute(T context) throws SagaStepException;
8 void compensate(T context) throws SagaStepException;
9 
10 default Duration getTimeout() { return Duration.ofSeconds(10); }
11 default int getMaxRetries() { return 1; }
12 default Duration getRetryBackoff() { return Duration.ofMillis(200); }
13}
14 

Step 4: Saga Orchestrator

java
1package com.example.saga;
2 
3import com.example.saga.entity.SagaStateEntity;
4import com.example.saga.entity.SagaStatus;
5import com.fasterxml.jackson.databind.ObjectMapper;
6import org.slf4j.Logger;
7import org.slf4j.LoggerFactory;
8import org.springframework.stereotype.Service;
9 
10import java.util.*;
11 
12@Service
13public class SagaOrchestrator {
14 
15 private static final Logger log = LoggerFactory.getLogger(SagaOrchestrator.class);
16 
17 private final SagaStateRepository repository;
18 private final ObjectMapper objectMapper;
19 
20 public SagaOrchestrator(SagaStateRepository repository, ObjectMapper objectMapper) {
21 this.repository = repository;
22 this.objectMapper = objectMapper;
23 }
24 
25 @SuppressWarnings("unchecked")
26 public <T> UUID execute(String sagaType, List<SagaStep<T>> steps, T context) {
27 Map<String, Object> contextMap = objectMapper.convertValue(context, Map.class);
28 SagaStateEntity state = SagaStateEntity.create(sagaType, contextMap);
29 repository.save(state);
30 
31 log.info("Saga {} started: type={}, steps={}", state.getId(), sagaType, steps.size());
32 
33 try {
34 runSteps(state, steps, context);
35 } catch (Exception e) {
36 log.error("Saga {} failed: {}", state.getId(), e.getMessage());
37 throw new SagaExecutionException("Saga failed: " + e.getMessage(), e, state.getId());
38 }
39 
40 return state.getId();
41 }
42 
43 private <T> void runSteps(SagaStateEntity state, List<SagaStep<T>> steps, T context) {
44 for (int i = state.getCurrentStep(); i < steps.size(); i++) {
45 SagaStep<T> step = steps.get(i);
46 log.info("Saga {} step {}: executing", state.getId(), step.getName());
47 
48 try {
49 executeWithRetry(step, context);
50 
51 state.addCompletedStep(step.getName());
52 state.setContext(objectMapper.convertValue(context, Map.class));
53 repository.save(state);
54 
55 } catch (Exception e) {
56 log.error("Saga {} step {} failed: {}", state.getId(), step.getName(), e.getMessage());
57 
58 state.setStatus(SagaStatus.COMPENSATING);
59 state.setError(e.getMessage());
60 repository.save(state);
61 
62 compensateAll(state, steps, context);
63 throw e;
64 }
65 }
66 
67 state.setStatus(SagaStatus.COMPLETED);
68 repository.save(state);
69 log.info("Saga {} completed", state.getId());
70 }
71 
72 private <T> void executeWithRetry(SagaStep<T> step, T context) throws SagaStepException {
73 SagaStepException lastError = null;
74 
75 for (int attempt = 0; attempt < step.getMaxRetries(); attempt++) {
76 if (attempt > 0) {
77 try {
78 Thread.sleep(step.getRetryBackoff().toMillis() * (long) Math.pow(2, attempt - 1));
79 } catch (InterruptedException ie) {
80 Thread.currentThread().interrupt();
81 throw new SagaStepException("Interrupted", ie, false);
82 }
83 }
84 
85 try {
86 step.execute(context);
87 return;
88 } catch (SagaStepException e) {
89 lastError = e;
90 if (!e.isRetryable()) throw e;
91 log.warn("Step {} attempt {}/{} failed: {}",
92 step.getName(), attempt + 1, step.getMaxRetries(), e.getMessage());
93 }
94 }
95 
96 throw lastError;
97 }
98 
99 private <T> void compensateAll(SagaStateEntity state, List<SagaStep<T>> steps, T context) {
100 String[] completed = state.getCompletedSteps();
101 List<String> errors = new ArrayList<>();
102 
103 for (int i = completed.length - 1; i >= 0; i--) {
104 String stepName = completed[i];
105 SagaStep<T> step = steps.stream()
106 .filter(s -> s.getName().equals(stepName))
107 .findFirst()
108 .orElse(null);
109 
110 if (step == null) continue;
111 
112 log.info("Saga {} compensating step {}", state.getId(), stepName);
113 
114 try {
115 step.compensate(context);
116 } catch (SagaStepException e) {
117 log.error("Compensation failed for step {}: {}", stepName, e.getMessage());
118 errors.add(stepName + ": " + e.getMessage());
119 }
120 }
121 
122 state.setStatus(SagaStatus.FAILED);
123 if (!errors.isEmpty()) {
124 state.setError(state.getError() + "; compensation errors: " + String.join(", ", errors));
125 }
126 repository.save(state);
127 }
128}
129 

Need a second opinion on your system design architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Step 5: Order Context and Steps

java
1package com.example.order;
2 
3import java.math.BigDecimal;
4import java.util.List;
5 
6public class OrderContext {
7 private String orderId;
8 private String customerId;
9 private List<OrderItem> items;
10 private BigDecimal totalAmount;
11 private String paymentMethodId;
12 private Address shippingAddress;
13 private String reservationId;
14 private String chargeId;
15 private String shipmentId;
16 private String trackingNumber;
17 
18 // Getters and setters for all fields
19 public String getOrderId() { return orderId; }
20 public void setOrderId(String orderId) { this.orderId = orderId; }
21 public String getCustomerId() { return customerId; }
22 public void setCustomerId(String customerId) { this.customerId = customerId; }
23 public List<OrderItem> getItems() { return items; }
24 public void setItems(List<OrderItem> items) { this.items = items; }
25 public BigDecimal getTotalAmount() { return totalAmount; }
26 public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
27 public String getPaymentMethodId() { return paymentMethodId; }
28 public void setPaymentMethodId(String paymentMethodId) { this.paymentMethodId = paymentMethodId; }
29 public Address getShippingAddress() { return shippingAddress; }
30 public void setShippingAddress(Address shippingAddress) { this.shippingAddress = shippingAddress; }
31 public String getReservationId() { return reservationId; }
32 public void setReservationId(String reservationId) { this.reservationId = reservationId; }
33 public String getChargeId() { return chargeId; }
34 public void setChargeId(String chargeId) { this.chargeId = chargeId; }
35 public String getShipmentId() { return shipmentId; }
36 public void setShipmentId(String shipmentId) { this.shipmentId = shipmentId; }
37 public String getTrackingNumber() { return trackingNumber; }
38 public void setTrackingNumber(String trackingNumber) { this.trackingNumber = trackingNumber; }
39}
40 
java
1// ReserveInventoryStep.java
2package com.example.order.steps;
3 
4import com.example.order.OrderContext;
5import com.example.saga.SagaStep;
6import com.example.saga.SagaStepException;
7import com.example.inventory.InventoryClient;
8import org.slf4j.Logger;
9import org.slf4j.LoggerFactory;
10import org.springframework.stereotype.Component;
11import java.time.Duration;
12 
13@Component
14public class ReserveInventoryStep implements SagaStep<OrderContext> {
15 
16 private static final Logger log = LoggerFactory.getLogger(ReserveInventoryStep.class);
17 private final InventoryClient inventoryClient;
18 
19 public ReserveInventoryStep(InventoryClient inventoryClient) {
20 this.inventoryClient = inventoryClient;
21 }
22 
23 @Override public String getName() { return "reserve_inventory"; }
24 @Override public Duration getTimeout() { return Duration.ofSeconds(5); }
25 @Override public int getMaxRetries() { return 3; }
26 @Override public Duration getRetryBackoff() { return Duration.ofMillis(200); }
27 
28 @Override
29 public void execute(OrderContext ctx) throws SagaStepException {
30 try {
31 var result = inventoryClient.reserve(
32 ctx.getOrderId(), ctx.getItems(), ctx.getOrderId() + "-reserve"
33 );
34 ctx.setReservationId(result.reservationId());
35 log.info("Inventory reserved: {}", result.reservationId());
36 } catch (Exception e) {
37 throw new SagaStepException("Failed to reserve inventory", e, true);
38 }
39 }
40 
41 @Override
42 public void compensate(OrderContext ctx) throws SagaStepException {
43 if (ctx.getReservationId() == null) return;
44 try {
45 inventoryClient.release(ctx.getReservationId(), ctx.getOrderId() + "-release");
46 log.info("Inventory released: {}", ctx.getReservationId());
47 } catch (Exception e) {
48 throw new SagaStepException("Failed to release inventory", e, true);
49 }
50 }
51}
52 
java
1// ChargePaymentStep.java
2package com.example.order.steps;
3 
4import com.example.order.OrderContext;
5import com.example.saga.SagaStep;
6import com.example.saga.SagaStepException;
7import com.example.payment.PaymentClient;
8import org.springframework.stereotype.Component;
9import java.time.Duration;
10 
11@Component
12public class ChargePaymentStep implements SagaStep<OrderContext> {
13 
14 private final PaymentClient paymentClient;
15 
16 public ChargePaymentStep(PaymentClient paymentClient) {
17 this.paymentClient = paymentClient;
18 }
19 
20 @Override public String getName() { return "charge_payment"; }
21 @Override public Duration getTimeout() { return Duration.ofSeconds(30); }
22 @Override public int getMaxRetries() { return 2; }
23 
24 @Override
25 public void execute(OrderContext ctx) throws SagaStepException {
26 try {
27 var result = paymentClient.charge(
28 ctx.getOrderId(), ctx.getTotalAmount(),
29 ctx.getPaymentMethodId(), ctx.getOrderId() + "-charge"
30 );
31 ctx.setChargeId(result.chargeId());
32 } catch (Exception e) {
33 throw new SagaStepException("Payment failed", e, true);
34 }
35 }
36 
37 @Override
38 public void compensate(OrderContext ctx) throws SagaStepException {
39 if (ctx.getChargeId() == null) return;
40 try {
41 paymentClient.refund(ctx.getChargeId(), ctx.getOrderId() + "-refund");
42 } catch (Exception e) {
43 throw new SagaStepException("Refund failed", e, true);
44 }
45 }
46}
47 
java
1// CreateShipmentStep.java
2package com.example.order.steps;
3 
4import com.example.order.OrderContext;
5import com.example.saga.SagaStep;
6import com.example.saga.SagaStepException;
7import com.example.shipping.ShippingClient;
8import org.springframework.stereotype.Component;
9import java.time.Duration;
10 
11@Component
12public class CreateShipmentStep implements SagaStep<OrderContext> {
13 
14 private final ShippingClient shippingClient;
15 
16 public CreateShipmentStep(ShippingClient shippingClient) {
17 this.shippingClient = shippingClient;
18 }
19 
20 @Override public String getName() { return "create_shipment"; }
21 @Override public Duration getTimeout() { return Duration.ofSeconds(10); }
22 
23 @Override
24 public void execute(OrderContext ctx) throws SagaStepException {
25 try {
26 var result = shippingClient.create(
27 ctx.getOrderId(), ctx.getShippingAddress(),
28 ctx.getItems(), ctx.getOrderId() + "-ship"
29 );
30 ctx.setShipmentId(result.shipmentId());
31 ctx.setTrackingNumber(result.trackingNumber());
32 } catch (Exception e) {
33 throw new SagaStepException("Shipment creation failed", e, true);
34 }
35 }
36 
37 @Override
38 public void compensate(OrderContext ctx) throws SagaStepException {
39 if (ctx.getShipmentId() == null) return;
40 try {
41 shippingClient.cancel(ctx.getShipmentId());
42 } catch (Exception e) {
43 throw new SagaStepException("Shipment cancellation failed", e, true);
44 }
45 }
46}
47 

Step 6: Order Service and Controller

java
1// OrderService.java
2package com.example.order;
3 
4import com.example.saga.SagaOrchestrator;
5import com.example.saga.SagaStep;
6import com.example.order.steps.*;
7import org.springframework.stereotype.Service;
8 
9import java.util.List;
10import java.util.UUID;
11 
12@Service
13public class OrderService {
14 
15 private final SagaOrchestrator orchestrator;
16 private final List<SagaStep<OrderContext>> steps;
17 
18 public OrderService(
19 SagaOrchestrator orchestrator,
20 ReserveInventoryStep reserveInventory,
21 ChargePaymentStep chargePayment,
22 CreateShipmentStep createShipment) {
23 this.orchestrator = orchestrator;
24 this.steps = List.of(reserveInventory, chargePayment, createShipment);
25 }
26 
27 public UUID fulfillOrder(OrderContext context) {
28 return orchestrator.execute("order-fulfillment", steps, context);
29 }
30}
31 
java
1// OrderController.java
2package com.example.order;
3 
4import com.example.saga.SagaStateRepository;
5import jakarta.validation.Valid;
6import jakarta.validation.constraints.NotBlank;
7import jakarta.validation.constraints.NotEmpty;
8import org.springframework.http.HttpStatus;
9import org.springframework.http.ResponseEntity;
10import org.springframework.web.bind.annotation.*;
11 
12import java.math.BigDecimal;
13import java.util.List;
14import java.util.Map;
15import java.util.UUID;
16 
17@RestController
18@RequestMapping("/api/orders")
19public class OrderController {
20 
21 private final OrderService orderService;
22 private final SagaStateRepository sagaRepository;
23 
24 public OrderController(OrderService orderService, SagaStateRepository sagaRepository) {
25 this.orderService = orderService;
26 this.sagaRepository = sagaRepository;
27 }
28 
29 @PostMapping
30 public ResponseEntity<?> createOrder(@Valid @RequestBody CreateOrderRequest request) {
31 var context = new OrderContext();
32 context.setOrderId(UUID.randomUUID().toString());
33 context.setCustomerId(request.customerId());
34 context.setItems(request.items());
35 context.setTotalAmount(request.items().stream()
36 .map(i -> i.price().multiply(BigDecimal.valueOf(i.quantity())))
37 .reduce(BigDecimal.ZERO, BigDecimal::add));
38 context.setPaymentMethodId(request.paymentMethodId());
39 context.setShippingAddress(request.shippingAddress());
40 
41 try {
42 UUID sagaId = orderService.fulfillOrder(context);
43 return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
44 "sagaId", sagaId,
45 "orderId", context.getOrderId(),
46 "trackingNumber", context.getTrackingNumber() != null ? context.getTrackingNumber() : "",
47 "status", "completed"
48 ));
49 } catch (Exception e) {
50 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
51 "orderId", context.getOrderId(),
52 "status", "failed",
53 "error", e.getMessage()
54 ));
55 }
56 }
57 
58 @GetMapping("/saga/{id}")
59 public ResponseEntity<?> getSagaStatus(@PathVariable UUID id) {
60 return sagaRepository.findById(id)
61 .map(ResponseEntity::ok)
62 .orElse(ResponseEntity.notFound().build());
63 }
64 
65 record CreateOrderRequest(
66 @NotBlank String customerId,
67 @NotEmpty List<OrderItem> items,
68 @NotBlank String paymentMethodId,
69 Address shippingAddress
70 ) {}
71}
72 

Step 7: Monitoring Dashboard Endpoint

java
1package com.example.saga;
2 
3import com.example.saga.entity.SagaStateEntity;
4import org.springframework.http.ResponseEntity;
5import org.springframework.web.bind.annotation.*;
6 
7import java.util.List;
8import java.util.Map;
9 
10@RestController
11@RequestMapping("/api/admin/sagas")
12public class SagaAdminController {
13 
14 private final SagaStateRepository repository;
15 
16 public SagaAdminController(SagaStateRepository repository) {
17 this.repository = repository;
18 }
19 
20 @GetMapping("/stats")
21 public ResponseEntity<?> stats() {
22 long running = repository.countByStatus("RUNNING");
23 long completed = repository.countByStatus("COMPLETED");
24 long failed = repository.countByStatus("FAILED");
25 long compensating = repository.countByStatus("COMPENSATING");
26 
27 return ResponseEntity.ok(Map.of(
28 "running", running,
29 "completed", completed,
30 "failed", failed,
31 "compensating", compensating,
32 "total", running + completed + failed + compensating
33 ));
34 }
35 
36 @GetMapping("/failed")
37 public ResponseEntity<List<SagaStateEntity>> failedSagas(
38 @RequestParam(defaultValue = "20") int limit) {
39 return ResponseEntity.ok(repository.findByStatusOrderByCreatedAtDesc("FAILED", limit));
40 }
41}
42 

Step 8: Integration Testing

java
1package com.example.order;
2 
3import com.example.saga.SagaOrchestrator;
4import com.example.saga.SagaStateRepository;
5import com.example.saga.entity.SagaStatus;
6import org.junit.jupiter.api.Test;
7import org.springframework.beans.factory.annotation.Autowired;
8import org.springframework.boot.test.context.SpringBootTest;
9import org.springframework.boot.test.mock.mockito.MockBean;
10 
11import java.math.BigDecimal;
12import java.util.List;
13 
14import static org.assertj.core.api.Assertions.*;
15import static org.mockito.ArgumentMatchers.any;
16import static org.mockito.Mockito.*;
17 
18@SpringBootTest
19class OrderSagaIntegrationTest {
20 
21 @Autowired private OrderService orderService;
22 @Autowired private SagaStateRepository sagaRepository;
23 
24 @MockBean private InventoryClient inventoryClient;
25 @MockBean private PaymentClient paymentClient;
26 @MockBean private ShippingClient shippingClient;
27 
28 @Test
29 void successfulOrderFulfillment() {
30 when(inventoryClient.reserve(any(), any(), any()))
31 .thenReturn(new ReservationResult("res-123"));
32 when(paymentClient.charge(any(), any(), any(), any()))
33 .thenReturn(new ChargeResult("ch-456"));
34 when(shippingClient.create(any(), any(), any(), any()))
35 .thenReturn(new ShipmentResult("ship-789", "TRK001"));
36 
37 var ctx = createTestContext();
38 var sagaId = orderService.fulfillOrder(ctx);
39 
40 var state = sagaRepository.findById(sagaId).orElseThrow();
41 assertThat(state.getStatus()).isEqualTo(SagaStatus.COMPLETED);
42 assertThat(state.getCompletedSteps()).hasSize(3);
43 assertThat(ctx.getTrackingNumber()).isEqualTo("TRK001");
44 }
45 
46 @Test
47 void compensatesOnPaymentFailure() {
48 when(inventoryClient.reserve(any(), any(), any()))
49 .thenReturn(new ReservationResult("res-123"));
50 when(paymentClient.charge(any(), any(), any(), any()))
51 .thenThrow(new RuntimeException("Insufficient funds"));
52 
53 var ctx = createTestContext();
54 
55 assertThatThrownBy(() -> orderService.fulfillOrder(ctx))
56 .hasMessageContaining("Insufficient funds");
57 
58 verify(inventoryClient).release(eq("res-123"), any());
59 }
60 
61 private OrderContext createTestContext() {
62 var ctx = new OrderContext();
63 ctx.setOrderId("test-order-1");
64 ctx.setCustomerId("cust-1");
65 ctx.setItems(List.of(new OrderItem("prod-1", 1, new BigDecimal("29.99"))));
66 ctx.setTotalAmount(new BigDecimal("29.99"));
67 ctx.setPaymentMethodId("pm-test");
68 ctx.setShippingAddress(new Address("123 Main St", "NYC", "NY", "10001", "US"));
69 return ctx;
70 }
71}
72 

Conclusion

Spring Boot's dependency injection, JPA persistence, and testing infrastructure make it an efficient platform for building saga orchestrators. The SagaStep<T> interface with Spring's @Component scanning means each step is a first-class Spring bean with automatic dependency resolution — inventory clients, payment gateways, and shipping services are injected without manual wiring.

JPA's @Version annotation handles optimistic locking for saga state updates, preventing concurrent modifications when multiple threads or instances process sagas simultaneously. Combined with PostgreSQL's JSONB column for the saga context, you get flexible context storage with type-safe Java objects through Jackson's ObjectMapper.

The integration test demonstrates Spring Boot's testing strength: @MockBean replaces external service clients with mocks, letting you verify saga compensation logic without real infrastructure. Test each failure scenario — payment decline, inventory shortage, shipping timeout — to confirm that compensation runs in the correct order and all previous steps are rolled back.

FAQ

Need expert help?

Building with system design?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026