Back to Journal
System Design

Complete Guide to Saga Pattern Implementation with Java

A comprehensive guide to implementing Saga Pattern Implementation using Java, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 17 min read

Java's strong typing, mature ecosystem, and enterprise patterns make it a natural choice for saga implementations in large-scale systems. This guide covers building a saga orchestrator using Spring Boot with JPA persistence, proper exception handling, and production-ready patterns. All examples use Java 17+ features and compile against Spring Boot 3.x.

Core Domain Model

Define the saga state and step abstractions as a clean domain model.

java
1// SagaState.java
2package com.example.saga.domain;
3 
4import jakarta.persistence.*;
5import java.time.Instant;
6import java.util.ArrayList;
7import java.util.List;
8 
9@Entity
10@Table(name = "saga_state")
11public class SagaState {
12 
13 @Id
14 private String id;
15 
16 @Column(name = "saga_type", nullable = false)
17 private String sagaType;
18 
19 @Enumerated(EnumType.STRING)
20 @Column(nullable = false)
21 private FlowStatus status;
22 
23 @Column(name = "current_step", nullable = false)
24 private int currentStep;
25 
26 @Column(columnDefinition = "jsonb")
27 private String context;
28 
29 @ElementCollection
30 @CollectionTable(name = "saga_completed_steps", joinColumns = @JoinColumn(name = "saga_id"))
31 @Column(name = "step_name")
32 @OrderColumn(name = "step_order")
33 private List<String> completedSteps = new ArrayList<>();
34 
35 private String error;
36 
37 @Version
38 private int version;
39 
40 @Column(name = "created_at", nullable = false)
41 private Instant createdAt;
42 
43 @Column(name = "updated_at", nullable = false)
44 private Instant updatedAt;
45 
46 // Constructors, getters, setters omitted for brevity
47 public static SagaState create(String id, String sagaType, String context) {
48 var state = new SagaState();
49 state.id = id;
50 state.sagaType = sagaType;
51 state.status = FlowStatus.RUNNING;
52 state.currentStep = 0;
53 state.context = context;
54 state.createdAt = Instant.now();
55 state.updatedAt = Instant.now();
56 return state;
57 }
58 
59 public void markStepCompleted(String stepName) {
60 this.completedSteps.add(stepName);
61 this.currentStep++;
62 this.updatedAt = Instant.now();
63 }
64 
65 public void markCompensating(String errorMessage) {
66 this.status = FlowStatus.COMPENSATING;
67 this.error = errorMessage;
68 this.updatedAt = Instant.now();
69 }
70 
71 public void markCompleted() {
72 this.status = FlowStatus.COMPLETED;
73 this.updatedAt = Instant.now();
74 }
75 
76 public void markFailed(String errorMessage) {
77 this.status = FlowStatus.FAILED;
78 this.error = errorMessage;
79 this.updatedAt = Instant.now();
80 }
81}
82 
java
1// FlowStatus.java
2package com.example.saga.domain;
3 
4public enum FlowStatus {
5 RUNNING,
6 COMPENSATING,
7 COMPLETED,
8 FAILED
9}
10 

Step Definition Interface

java
1// SagaStep.java
2package com.example.saga;
3 
4import java.time.Duration;
5 
6public interface SagaStep<T> {
7 String getName();
8 void execute(T context) throws SagaStepException;
9 void compensate(T context) throws SagaStepException;
10 Duration getTimeout();
11 
12 default RetryPolicy getRetryPolicy() {
13 return new RetryPolicy(1, Duration.ofMillis(100), 2.0, Duration.ofSeconds(5));
14 }
15}
16 
java
1// RetryPolicy.java
2package com.example.saga;
3 
4import java.time.Duration;
5 
6public record RetryPolicy(
7 int maxAttempts,
8 Duration initialBackoff,
9 double multiplier,
10 Duration maxBackoff
11) {}
12 
java
1// SagaStepException.java
2package com.example.saga;
3 
4public class SagaStepException extends Exception {
5 private final boolean retryable;
6 
7 public SagaStepException(String message, boolean retryable) {
8 super(message);
9 this.retryable = retryable;
10 }
11 
12 public SagaStepException(String message, Throwable cause, boolean retryable) {
13 super(message, cause);
14 this.retryable = retryable;
15 }
16 
17 public boolean isRetryable() {
18 return retryable;
19 }
20}
21 

Saga Orchestrator

java
1// SagaOrchestrator.java
2package com.example.saga;
3 
4import com.example.saga.domain.SagaState;
5import com.fasterxml.jackson.databind.ObjectMapper;
6import org.slf4j.Logger;
7import org.slf4j.LoggerFactory;
8import org.springframework.transaction.annotation.Transactional;
9 
10import java.time.Duration;
11import java.util.*;
12 
13public class SagaOrchestrator<T> {
14 
15 private static final Logger log = LoggerFactory.getLogger(SagaOrchestrator.class);
16 
17 private final List<SagaStep<T>> steps;
18 private final SagaStateRepository repository;
19 private final ObjectMapper objectMapper;
20 private final Class<T> contextType;
21 
22 public SagaOrchestrator(
23 List<SagaStep<T>> steps,
24 SagaStateRepository repository,
25 ObjectMapper objectMapper,
26 Class<T> contextType) {
27 this.steps = List.copyOf(steps);
28 this.repository = repository;
29 this.objectMapper = objectMapper;
30 this.contextType = contextType;
31 }
32 
33 public String execute(String sagaType, T context) {
34 try {
35 String contextJson = objectMapper.writeValueAsString(context);
36 String sagaId = UUID.randomUUID().toString();
37 SagaState state = SagaState.create(sagaId, sagaType, contextJson);
38 repository.save(state);
39 
40 log.info("Saga started: id={}, type={}, steps={}", sagaId, sagaType, steps.size());
41 
42 executeSteps(state, context);
43 return sagaId;
44 
45 } catch (Exception e) {
46 throw new SagaExecutionException("Failed to execute saga", e);
47 }
48 }
49 
50 private void executeSteps(SagaState state, T context) {
51 for (int i = state.getCurrentStep(); i < steps.size(); i++) {
52 SagaStep<T> step = steps.get(i);
53 
54 log.info("Executing step: saga={}, step={}", state.getId(), step.getName());
55 
56 try {
57 executeWithRetry(step, context);
58 state.markStepCompleted(step.getName());
59 persistContext(state, context);
60 repository.save(state);
61 
62 } catch (Exception e) {
63 log.error("Step failed: saga={}, step={}, error={}",
64 state.getId(), step.getName(), e.getMessage());
65 
66 state.markCompensating(e.getMessage());
67 repository.save(state);
68 
69 compensate(state, context);
70 return;
71 }
72 }
73 
74 state.markCompleted();
75 repository.save(state);
76 log.info("Saga completed: id={}", state.getId());
77 }
78 
79 private void executeWithRetry(SagaStep<T> step, T context) throws SagaStepException {
80 RetryPolicy policy = step.getRetryPolicy();
81 SagaStepException lastException = null;
82 
83 for (int attempt = 0; attempt < policy.maxAttempts(); attempt++) {
84 try {
85 if (attempt > 0) {
86 long backoffMs = (long) (policy.initialBackoff().toMillis()
87 * Math.pow(policy.multiplier(), attempt - 1));
88 backoffMs = Math.min(backoffMs, policy.maxBackoff().toMillis());
89 Thread.sleep(backoffMs);
90 }
91 
92 step.execute(context);
93 return;
94 
95 } catch (SagaStepException e) {
96 lastException = e;
97 if (!e.isRetryable()) throw e;
98 
99 log.warn("Step attempt failed: step={}, attempt={}/{}, error={}",
100 step.getName(), attempt + 1, policy.maxAttempts(), e.getMessage());
101 
102 } catch (InterruptedException e) {
103 Thread.currentThread().interrupt();
104 throw new SagaStepException("Interrupted during retry backoff", e, false);
105 }
106 }
107 
108 throw lastException;
109 }
110 
111 private void compensate(SagaState state, T context) {
112 List<String> toCompensate = new ArrayList<>(state.getCompletedSteps());
113 Collections.reverse(toCompensate);
114 List<String> compensationErrors = new ArrayList<>();
115 
116 for (String stepName : toCompensate) {
117 SagaStep<T> step = findStep(stepName);
118 if (step == null) continue;
119 
120 log.info("Compensating step: saga={}, step={}", state.getId(), stepName);
121 
122 try {
123 step.compensate(context);
124 } catch (SagaStepException e) {
125 log.error("Compensation failed: saga={}, step={}, error={}",
126 state.getId(), stepName, e.getMessage());
127 compensationErrors.add(stepName + ": " + e.getMessage());
128 }
129 }
130 
131 if (compensationErrors.isEmpty()) {
132 state.markFailed(state.getError());
133 } else {
134 state.markFailed(state.getError() + "; compensation errors: " +
135 String.join(", ", compensationErrors));
136 }
137 repository.save(state);
138 }
139 
140 private SagaStep<T> findStep(String name) {
141 return steps.stream()
142 .filter(s -> s.getName().equals(name))
143 .findFirst()
144 .orElse(null);
145 }
146 
147 private void persistContext(SagaState state, T context) {
148 try {
149 state.setContext(objectMapper.writeValueAsString(context));
150 } catch (Exception e) {
151 log.warn("Failed to persist saga context: {}", e.getMessage());
152 }
153 }
154}
155 

Example: Order Fulfillment Steps

java
1// ReserveInventoryStep.java
2package com.example.order.saga.steps;
3 
4import com.example.order.saga.OrderContext;
5import com.example.saga.SagaStep;
6import com.example.saga.SagaStepException;
7import com.example.saga.RetryPolicy;
8import com.example.inventory.InventoryService;
9import com.example.inventory.ReservationResult;
10 
11import java.time.Duration;
12 
13public class ReserveInventoryStep implements SagaStep<OrderContext> {
14 
15 private final InventoryService inventoryService;
16 
17 public ReserveInventoryStep(InventoryService inventoryService) {
18 this.inventoryService = inventoryService;
19 }
20 
21 @Override
22 public String getName() { return "reserve_inventory"; }
23 
24 @Override
25 public void execute(OrderContext ctx) throws SagaStepException {
26 try {
27 ReservationResult result = inventoryService.reserve(
28 ctx.getOrderId(),
29 ctx.getItems(),
30 ctx.getOrderId() + "-reserve" // idempotency key
31 );
32 ctx.setReservationId(result.getReservationId());
33 } catch (Exception e) {
34 boolean retryable = !(e instanceof IllegalArgumentException);
35 throw new SagaStepException("Inventory reservation failed", e, retryable);
36 }
37 }
38 
39 @Override
40 public void compensate(OrderContext ctx) throws SagaStepException {
41 if (ctx.getReservationId() == null) return;
42 
43 try {
44 inventoryService.release(
45 ctx.getReservationId(),
46 ctx.getOrderId() + "-release"
47 );
48 } catch (Exception e) {
49 throw new SagaStepException("Inventory release failed", e, true);
50 }
51 }
52 
53 @Override
54 public Duration getTimeout() { return Duration.ofSeconds(5); }
55 
56 @Override
57 public RetryPolicy getRetryPolicy() {
58 return new RetryPolicy(3, Duration.ofMillis(200), 2.0, Duration.ofSeconds(3));
59 }
60}
61 
java
1// ChargePaymentStep.java
2package com.example.order.saga.steps;
3 
4import com.example.order.saga.OrderContext;
5import com.example.saga.SagaStep;
6import com.example.saga.SagaStepException;
7import com.example.payment.PaymentService;
8import com.example.payment.ChargeResult;
9 
10import java.time.Duration;
11 
12public class ChargePaymentStep implements SagaStep<OrderContext> {
13 
14 private final PaymentService paymentService;
15 
16 public ChargePaymentStep(PaymentService paymentService) {
17 this.paymentService = paymentService;
18 }
19 
20 @Override
21 public String getName() { return "charge_payment"; }
22 
23 @Override
24 public void execute(OrderContext ctx) throws SagaStepException {
25 try {
26 ChargeResult result = paymentService.charge(
27 ctx.getOrderId(),
28 ctx.getTotalAmount(),
29 ctx.getPaymentMethodId(),
30 ctx.getOrderId() + "-charge"
31 );
32 ctx.setChargeId(result.getChargeId());
33 } catch (Exception e) {
34 throw new SagaStepException("Payment charge failed", e, true);
35 }
36 }
37 
38 @Override
39 public void compensate(OrderContext ctx) throws SagaStepException {
40 if (ctx.getChargeId() == null) return;
41 
42 try {
43 paymentService.refund(ctx.getChargeId(), ctx.getOrderId() + "-refund");
44 } catch (Exception e) {
45 throw new SagaStepException("Payment refund failed", e, true);
46 }
47 }
48 
49 @Override
50 public Duration getTimeout() { return Duration.ofSeconds(30); }
51}
52 

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

Spring Boot Configuration

java
1// SagaConfiguration.java
2package com.example.order.config;
3 
4import com.example.order.saga.OrderContext;
5import com.example.order.saga.steps.*;
6import com.example.saga.*;
7import com.fasterxml.jackson.databind.ObjectMapper;
8import org.springframework.context.annotation.Bean;
9import org.springframework.context.annotation.Configuration;
10 
11import java.util.List;
12 
13@Configuration
14public class SagaConfiguration {
15 
16 @Bean
17 public SagaOrchestrator<OrderContext> orderFulfillmentSaga(
18 SagaStateRepository repository,
19 ObjectMapper objectMapper,
20 ReserveInventoryStep reserveInventory,
21 ChargePaymentStep chargePayment,
22 CreateShipmentStep createShipment,
23 SendNotificationStep sendNotification) {
24 
25 List<SagaStep<OrderContext>> steps = List.of(
26 reserveInventory,
27 chargePayment,
28 createShipment,
29 sendNotification
30 );
31 
32 return new SagaOrchestrator<>(steps, repository, objectMapper, OrderContext.class);
33 }
34 
35 @Bean
36 public ReserveInventoryStep reserveInventoryStep(InventoryService inventoryService) {
37 return new ReserveInventoryStep(inventoryService);
38 }
39 
40 @Bean
41 public ChargePaymentStep chargePaymentStep(PaymentService paymentService) {
42 return new ChargePaymentStep(paymentService);
43 }
44 
45 @Bean
46 public CreateShipmentStep createShipmentStep(ShippingService shippingService) {
47 return new CreateShipmentStep(shippingService);
48 }
49 
50 @Bean
51 public SendNotificationStep sendNotificationStep(NotificationService notificationService) {
52 return new SendNotificationStep(notificationService);
53 }
54}
55 

REST Controller

java
1// OrderController.java
2package com.example.order.controller;
3 
4import com.example.order.saga.OrderContext;
5import com.example.saga.SagaOrchestrator;
6import org.springframework.http.HttpStatus;
7import org.springframework.http.ResponseEntity;
8import org.springframework.web.bind.annotation.*;
9 
10import java.util.Map;
11import java.util.UUID;
12 
13@RestController
14@RequestMapping("/api/orders")
15public class OrderController {
16 
17 private final SagaOrchestrator<OrderContext> orderSaga;
18 
19 public OrderController(SagaOrchestrator<OrderContext> orderSaga) {
20 this.orderSaga = orderSaga;
21 }
22 
23 @PostMapping
24 public ResponseEntity<?> createOrder(@RequestBody CreateOrderRequest request) {
25 var ctx = new OrderContext();
26 ctx.setOrderId(UUID.randomUUID().toString());
27 ctx.setCustomerId(request.customerId());
28 ctx.setItems(request.items());
29 ctx.setTotalAmount(request.totalAmount());
30 ctx.setPaymentMethodId(request.paymentMethodId());
31 ctx.setShippingAddress(request.shippingAddress());
32 
33 try {
34 String sagaId = orderSaga.execute("order-fulfillment", ctx);
35 
36 return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
37 "sagaId", sagaId,
38 "orderId", ctx.getOrderId(),
39 "trackingNumber", ctx.getTrackingNumber() != null ? ctx.getTrackingNumber() : "",
40 "status", "completed"
41 ));
42 } catch (Exception e) {
43 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
44 "error", e.getMessage(),
45 "orderId", ctx.getOrderId()
46 ));
47 }
48 }
49}
50 
51record CreateOrderRequest(
52 String customerId,
53 List<OrderItem> items,
54 double totalAmount,
55 String paymentMethodId,
56 Address shippingAddress
57) {}
58 

Testing with JUnit

java
1// SagaOrchestratorTest.java
2package com.example.saga;
3 
4import com.fasterxml.jackson.databind.ObjectMapper;
5import org.junit.jupiter.api.BeforeEach;
6import org.junit.jupiter.api.Test;
7 
8import java.time.Duration;
9import java.util.ArrayList;
10import java.util.List;
11import java.util.concurrent.atomic.AtomicBoolean;
12 
13import static org.assertj.core.api.Assertions.*;
14 
15class SagaOrchestratorTest {
16 
17 private InMemorySagaStateRepository repository;
18 private ObjectMapper objectMapper;
19 
20 @BeforeEach
21 void setUp() {
22 repository = new InMemorySagaStateRepository();
23 objectMapper = new ObjectMapper();
24 }
25 
26 @Test
27 void completesAllStepsSuccessfully() {
28 var step1Executed = new AtomicBoolean(false);
29 var step2Executed = new AtomicBoolean(false);
30 
31 var steps = List.<SagaStep<TestContext>>of(
32 createStep("step1", ctx -> step1Executed.set(true), ctx -> {}),
33 createStep("step2", ctx -> step2Executed.set(true), ctx -> {})
34 );
35 
36 var orchestrator = new SagaOrchestrator<>(steps, repository, objectMapper, TestContext.class);
37 String sagaId = orchestrator.execute("test", new TestContext("value"));
38 
39 assertThat(step1Executed).isTrue();
40 assertThat(step2Executed).isTrue();
41 
42 var state = repository.findById(sagaId).orElseThrow();
43 assertThat(state.getStatus()).isEqualTo(FlowStatus.COMPLETED);
44 }
45 
46 @Test
47 void compensatesCompletedStepsOnFailure() {
48 var step1Compensated = new AtomicBoolean(false);
49 
50 var steps = List.<SagaStep<TestContext>>of(
51 createStep("step1", ctx -> {}, ctx -> step1Compensated.set(true)),
52 createStep("step2",
53 ctx -> { throw new SagaStepException("Forced failure", false); },
54 ctx -> {})
55 );
56 
57 var orchestrator = new SagaOrchestrator<>(steps, repository, objectMapper, TestContext.class);
58 
59 assertThatThrownBy(() -> orchestrator.execute("test", new TestContext("value")))
60 .isInstanceOf(SagaExecutionException.class);
61 
62 assertThat(step1Compensated).isTrue();
63 }
64 
65 private SagaStep<TestContext> createStep(
66 String name,
67 ThrowingConsumer<TestContext> execute,
68 ThrowingConsumer<TestContext> compensate) {
69 return new SagaStep<>() {
70 public String getName() { return name; }
71 public void execute(TestContext ctx) throws SagaStepException { execute.accept(ctx); }
72 public void compensate(TestContext ctx) throws SagaStepException { compensate.accept(ctx); }
73 public Duration getTimeout() { return Duration.ofSeconds(5); }
74 };
75 }
76 
77 record TestContext(String value) {}
78 
79 @FunctionalInterface
80 interface ThrowingConsumer<T> {
81 void accept(T t) throws SagaStepException;
82 }
83}
84 

Monitoring Endpoint

java
1// SagaMonitoringController.java
2package com.example.saga.monitoring;
3 
4import com.example.saga.SagaStateRepository;
5import org.springframework.http.ResponseEntity;
6import org.springframework.web.bind.annotation.*;
7 
8import java.util.Map;
9 
10@RestController
11@RequestMapping("/api/admin/sagas")
12public class SagaMonitoringController {
13 
14 private final SagaStateRepository repository;
15 
16 public SagaMonitoringController(SagaStateRepository repository) {
17 this.repository = repository;
18 }
19 
20 @GetMapping("/stats")
21 public ResponseEntity<?> getStats() {
22 var stats = Map.of(
23 "running", repository.countByStatus("RUNNING"),
24 "completed", repository.countByStatus("COMPLETED"),
25 "failed", repository.countByStatus("FAILED"),
26 "compensating", repository.countByStatus("COMPENSATING")
27 );
28 return ResponseEntity.ok(stats);
29 }
30 
31 @GetMapping("/{sagaId}")
32 public ResponseEntity<?> getSaga(@PathVariable String sagaId) {
33 return repository.findById(sagaId)
34 .map(ResponseEntity::ok)
35 .orElse(ResponseEntity.notFound().build());
36 }
37 
38 @GetMapping("/failed")
39 public ResponseEntity<?> getFailedSagas(
40 @RequestParam(defaultValue = "0") int page,
41 @RequestParam(defaultValue = "20") int size) {
42 var failed = repository.findByStatus("FAILED", page, size);
43 return ResponseEntity.ok(failed);
44 }
45}
46 

Conclusion

Java's ecosystem provides strong building blocks for saga implementations: JPA for state persistence with optimistic locking via @Version, Spring's dependency injection for composing steps from service beans, and the type system for enforcing step contracts through interfaces. The SagaStep<T> interface creates a clear contract that each participating service must fulfill — explicit execute, compensate, timeout, and retry definitions.

The Spring Boot configuration wires saga steps through standard dependency injection, meaning each step can have its own service dependencies injected by the framework. This keeps the saga orchestrator framework-agnostic while leveraging Spring's wiring for the concrete step implementations.

For production deployments, add Micrometer metrics to the orchestrator (saga start/complete/fail counters, step duration histograms) and configure Spring Actuator endpoints for health checks. The monitoring controller provides basic visibility, but integrating with Grafana or Datadog dashboards gives the operational depth needed to manage sagas at scale.

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