Saga Executors
Overview
A Saga Executor in StackSaga is a specialized component responsible for encapsulating and executing a single atomic transaction within a distributed saga workflow. It acts as the bridge between your business logic and the saga orchestration engine, ensuring that each step in a long-running transaction is performed reliably and consistently. Saga Executors are categorized as either command executors (handling both primary and compensating executions for operations that modify state) or query executors (handling read-only operations). By isolating each atomic transaction in its own executor, StackSaga enables precise control, retry, and compensation mechanisms, reducing the risk of data anomalies and ensuring eventual consistency across microservices.
| If you are new to the saga pattern, it is recommended to read the Atomic Transactions & Idempotency in LRT to understand the concept of idempotency and atomic operations in distributed systems. |
Typically, an atomic execution consists of two phases: primary execution and compensating execution.
-
Primary Execution (Main Execution)
-
The primary execution represents the core operation within a long-running transaction. Each primary execution is a distinct step in the overall business workflow, advancing the process toward completion. These operations are generally idempotent and isolated, ensuring they can be executed independently without unintended side effects.
For example, in a place-order workflow, primary executions might include fetching user details, initializing the order, performing pre-authorization, updating stock, and processing the final payment.
-
These primary executions move the process forward. []
-
-
Compensating Execution (Revert Execution)
-
A compensating execution is designed to undo the effects of a previously completed primary execution if a subsequent step fails. Each primary execution has a corresponding compensating action to reverse its impact, ensuring the system can return to a consistent state in the event of a failure.
-
Compensating executions move the process backward. []
-
Executor types
Based on the above classification, the saga executors are two types mainly. but there is a special type of executor called sub executor in Stacksaga other than the command executor and query executor to handle some extra compensating executions.
The below diagram shows the Executors types and what are the methods they have.
| Each above executor types has blocking and non-blocking (reactive) implementations. you can choose the one that fits your needs. |
Command executors
If some atomic execution has both primary execution and compensating, those kinds of atomic transactions should be implemented inside the command executor.
In Command executor has two methods for making the primary execution and making the compensating execution.
Examples Executions for command-executor:
-
Initialize order
-
Reserve the items
-
Make the payment
Blocking Command-Executor
@SagaExecutor(executeFor = "order-service", value = "initializeOrderExecutor") (1)
@AllArgsConstructor
public class InitializeOrderExecutor implements CommandExecutor<OrderDomainEntity> { (2)
private final OrderService orderService;
@Override (3)
public ProcessStepManager<OrderDomainEntity> doProcess(
OrderDomainEntity currentDomainEntity,
ProcessStepManagerUtil<OrderDomainEntity> stepManager,
String idempotencyKey
) throws RetryableExecutorException, NonRetryableExecutorException {
try {
(4)
String orderId = this.orderService.initializeOrder(
currentDomainEntity.getUsername(),
currentDomainEntity.getProductItems(),
currentDomainEntity.getTotalAmount(),
idempotencyKey
);
currentDomainEntity.setOrderId(orderId);
return stepManager.next(RserveItemsExecutor.class, "INITIALIZED_ORDER"); (5)
} catch (OperationAlreadyExecutedException alreadyExecutedException) {
(6)
return stepManager.next(RserveItemsExecutor.class, "INITIALIZED_ORDER");
} catch (FeignException.ServiceUnavailable unavailableException) {
(7)
throw RetryableExecutorException.of(unavailableException);
} catch (FeignException.BadRequest badRequestException) {
(8)
throw NonRetryableExecutorException
.buildWith(badRequestException)
.put("time", LocalDateTime.now())
.put("reason", "BadRequest")
.build();
}
}
@Override
@RevertBefore(startFrom = OrderInitializeSubBeforeExecutor.class) (12)
@RevertAfter(startFrom = OrderInitializeSubAfterExecutor.class) (13)
public SagaExecutionEventName doRevert(
NonRetryableExecutorException processException,
OrderDomainEntity finalDomainEntityState,
RevertHintStore revertHintStore,
String idempotencyKey
) throws RetryableExecutorException {
try {
(9)
this.orderService.cancelOrder(finalDomainEntityState.getOrderId(), idempotencyKey);
return SagaExecutionEventName.of("CANCELLED_ORDER");
} catch (OperationAlreadyExecutedException alreadyExecutedException) {
(10)
return SagaExecutionEventName.of("CANCELLED_ORDER");
} catch (FeignException.ServiceUnavailable unavailableException) {
(11)
throw RetryableExecutorException.of(unavailableException);
} catch (FeignException.BadRequest badRequestException) {
revertHintStore.put("InitializeOrderExecutor:FAILED", processException.getMessage());
throw new RuntimeException();
}
}
}
| 1 | The executor is annotated with @SagaExecutor to make it as a spring bean and also to provide the necessary metadata such as the target service that the executor is executed for and the unique name of the executor. |
||
| 2 | The executor implements the CommandExecutor interface to make it as a command executor. |
||
| 3 | The doProcess method is overridden to implement the primary execution logic.StackSaga automatically supplies an idempotencyKey as the third parameter. This key is deterministic and stable across retries of this atomic execution. You pass this key to your downstream service (or in an HTTP/messaging header) so that the downstream service can detect duplicate requests.NOTE: To learn how StackSaga generates this key or how to customize it, refer to Generating Idempotency Keys in Domain Entity. |
||
| 4 | The primary execution logic is implemented inside the doProcess method. Pass the idempotencyKey along with your business arguments to the downstream service call. |
||
| 5 | If the execution is done successfully, the next executor is navigated by using the stepManager.next method.It should be provided the next executor class that you want to navigate and the event action name that will be stored in the event-store regarding the successful execution of the current executor. There is another method called stepManager.complete that can be used to complete the entire transaction successfully after executing the current executor:
|
||
| 6 | Handling Already-Executed Idempotent Responses (Developer Responsibility):
|
||
| 7 | Catch the resource unavailability that can be retried to notify the SEC to keep the transaction in retry mode and it will be exposed to the next retrying schedule as you configured. You can wrap the original exception by using the RetryableExecutorException.of(Exception e) method to create a new instance of RetryableExecutorException.If you throw your own exception without wrapping it in RetryableExecutorException, the SEC will consider it as a non-retryable exception, stop forward execution, and start compensating executions in reverse order immediately. |
||
| 8 | Catch the non-retryable exceptions to notify the SEC to stop forward execution and start compensating executions in reverse order immediately. You can wrap the original exception by using NonRetryableExecutorException.buildWith(Exception e) and attach any metadata to the event store using put(String key, Object value). This metadata can later be accessed in compensating executions via the RevertHintStore. |
||
| 9 | The doRevert method is overridden to implement the compensating execution logic.It also receives the idempotencyKey to forward to the downstream compensation endpoint to ensure the reversal action is idempotent. |
||
| 10 | Idempotent Compensation Return (Developer Responsibility): Just like in doProcess, handling duplicate execution during compensation is your responsibility as the developer. If the downstream service indicates that the cancellation or reversal was already executed on an earlier retry attempt (represented here by catching OperationAlreadyExecutedException), return the success event (SagaExecutionEventName.of("CANCELLED_ORDER")). This confirms to the SEC that the compensation for this step is successfully completed, allowing the framework to proceed with compensating the next previous step in reverse order (or successfully finalize the rollback if all compensations are done). |
||
| 11 | Catch the resource unavailability exception that can be retried in compensation to keep the transaction in retry mode. If there is a non-retryable exception in compensation that can be safely ignored, catch that exception and store metadata into RevertHintStore using put(String key, Object value) to avoid transaction termination. |
||
| 12 | The @RevertBefore annotation is used to specify a starting sub-before-executor that should run before the main compensating execution of this command executor. |
||
| 13 | The @RevertAfter annotation is used to specify a starting sub-after-executor that should run after the main compensating execution of this command executor. |
Non-Blocking Command-Executor
| Only highlighted parts are described from the above example. |
@SagaExecutor(executeFor = "order-service", value = "initializeOrderExecutor")
@AllArgsConstructor
public class ReactiveInitializeOrderExecutor implements ReactiveCommandExecutor<OrderDomainEntity> { (1)
private final OrderService orderService;
@Override (2)
@NonNull
public Mono<ProcessStepManager<OrderDomainEntity>> doProcess(
OrderDomainEntity currentDomainEntity,
ProcessStepManagerUtil<OrderDomainEntity> stepManager,
String idempotencyKey
) {
(3)
return this.orderService
.createOrder(
currentDomainEntity.getUserData(),
currentDomainEntity.getOrderDetails(),
idempotencyKey
)
.map(orderId -> {
currentDomainEntity.setOrderId(orderId);
return stepManager.next(ReactiveRserveItemsExecutor.class, "INITIALIZED_ORDER");
})
.onErrorResume(throwable -> {
if (throwable instanceof OperationAlreadyExecutedException) {
// Return normal success step for already-executed operations — do not fail
return Mono.just(stepManager.next(ReactiveRserveItemsExecutor.class, "INITIALIZED_ORDER"));
} else if (throwable instanceof ResourceUnavailableException) {
return Mono.error(RetryableExecutorException.of(throwable));
} else {
return Mono.error(NonRetryableExecutorException.buildWith(throwable)
.put("time", String.valueOf(System.currentTimeMillis()))
.put("reason", "BadRequest")
.build());
}
});
}
@Override (4)
@NonNull
public Mono<SagaExecutionEventName> doRevert(
NonRetryableExecutorException processException,
OrderDomainEntity finalDomainEntityState,
RevertHintStore revertHintStore,
String idempotencyKey
) {
(5)
return this.orderService
.cancelOrder(finalDomainEntityState.getOrderId(), idempotencyKey)
.thenReturn(SagaExecutionEventName.of("REVERTED_ORDER_INITIALIZATION"))
.onErrorResume(throwable -> {
if (throwable instanceof OperationAlreadyExecutedException) {
// Return success revert event for already-executed compensation
return Mono.just(SagaExecutionEventName.of("REVERTED_ORDER_INITIALIZATION"));
} else if (throwable instanceof ResourceUnavailableException) {
return Mono.error(RetryableExecutorException.of(throwable));
} else {
revertHintStore.put("InitializeOrderExecutor:FAILED", processException.getMessage());
SagaExecutionEventName ignored = SagaExecutionEventName.of("IGNORED");
return Mono.just(ignored);
}
});
}
}
| 1 | The executor implements the ReactiveCommandExecutor<OrderDomainEntity> interface to make it as a reactive command executor. the generic type should be the domain-entity class that you want to use in the executor. |
||
| 2 | The doProcess method is overridden to implement the primary execution logic in a reactive way. |
||
| 3 | The primary execution logic is implemented inside the doProcess method by using reactive pipeline.Notice that idempotencyKey is passed along to the downstream service call (createOrder). In onErrorResume, if a duplicate execution exception occurs (such as OperationAlreadyExecutedException), the pipeline recovers by returning Mono.just(stepManager.next(…)) to treat it as a success, rather than propagating an error.
|
||
| 4 | The doRevert method is overridden to implement the compensating execution logic in a reactive way. |
||
| 5 | The compensating execution logic is implemented inside the doRevert method by using reactive pipeline.The same idempotency policy applies: pass idempotencyKey to cancelOrder, and if an already-executed exception occurs in onErrorResume, return Mono.just(…) with the success revert event name so the framework recognizes this step as compensated.It can not have a non-retryable exception due to compensating execution. if there is any possibility to have a non-retryable exception, it should be caught and stored in the RevertHintStore and return a dummy event name like above example to avoid the transaction termination. |
Query executors
If the atomic execution has only the primary execution, those kinds of executions should be implemented inside the Query executor.
In Query Executor has only one method for making the primary execution.
Example Executions for query-executor:
-
collecting user delivery details
because it doesn’t make any change to the user-service’s database. it’s a read-only operation.
Blocking Query Executor
@SagaExecutor(executeFor = "user-service", value = "chekUserDetailsExecutor") (1)
@AllArgsConstructor
public class ChekUserDetailsExecutor implements QueryExecutor<OrderDomainEntity> { (2)
private final UserService userService;
@Override (3)
public ProcessStepManager<OrderDomainEntity> doProcess(
OrderDomainEntity currentDomainEntity,
ProcessStepManagerUtil<OrderDomainEntity> stepManager,
String idempotencyKey
) throws RetryableExecutorException, NonRetryableExecutorException {
try {
(4)
UserDetailDto userDetail = this.userService.getUserDetails(currentDomainEntity.getUsername());
currentDomainEntity.setUserDetail(userDetail);
return stepManager.next(InitializeOrderExecutor.class, "FETCHED_USER_DETAILS"); (5)
} catch (FeignException.ServiceUnavailable unavailableException) {
(6)
throw RetryableExecutorException.of(unavailableException);
} catch (FeignException.BadRequest badRequestException) {
(7)
throw NonRetryableExecutorException
.buildWith(badRequestException)
.put("time", LocalDateTime.now())
.put("reason", "BadRequest")
.build();
}
}
}
| 1 | The executor is annotated with @SagaExecutor to make it as a spring bean and also to provide the necessary metadata such as the target service that the executor is executed for and the unique name of the executor. |
| 2 | The executor implements the QueryExecutor interface to make it as a query executor. |
| 3 | The doProcess method is overridden to implement the primary execution logic.It passes the current state of the domain-entity, a utility for managing the process steps, and the framework-provided idempotencyKey. Because queries are read-only operations (naturally idempotent), duplicate request handling (such as catching already-executed exceptions) is not required for query executors. |
| 4 | The primary execution logic is implemented inside the doProcess method. here you can update the domain-entity as needed and access data from the domain-entity. it contains all the changes that were made by the previous executors so far. |
| 5 | if the execution is done successfully, The next executor is navigated by using the stepManager.next method.it should be provided the next executor class that you want to navigate and also the event name supplier that will be used to store the event name in the event-store regarding the successful execution of the current executor. There is another method called stepManager.complete that can be used to complete the entire transaction successfully after executing the current executor.
|
| 6 | Catch the resource unavailability that can be retried to notify the SEC to keep the transaction in retry mode and it will be exposed to the next retrying schedule as you configured. you can wrap the original exception by using the RetryableExecutorException.of(Exception e) method to create a new instance of RetryableExecutorException by wrapping the original exception.if you throw your own exception without wrapping to the RetryableExecutorException, the SEC will consider it as a non-retryable exception and it will stop the transaction forward and start the compensating executions in reverse order immediately. |
| 7 | Catch the non-retryable exceptions to notify to the SEC to stop the transaction forward and start the compensating executions in reverse order immediately. you can wrap the original exception by using the NonRetryableExecutorException.buildWith(Exception e) method to create a new instance of NonRetryableExecutorException by wrapping the original exception, and also you can add any metadata that you want to store in the event-store regarding the exception by using the put(String key, Object value) method.it can be accessed later in the next compensating execution by using the RevertHintStore that is passed to the doRevert method of the command executor.if you throw your own exception without wrapping to the NonRetryableExecutorException, it doesn’t matter in this case, because the SEC will consider it as a non-retryable exception internally, and it will stop the transaction forward and start the compensating executions in reverse order immediately. |
Non-Blocking Query-Executor
@SagaExecutor(executeFor = "user-service", value = "reactiveChekUserDetailsExecutor")
@AllArgsConstructor
public class ReactiveChekUserDetailsExecutor implements ReactiveQueryExecutor<OrderDomainEntity> { (1)
private final UserService userService;
@Override (2)
public Mono<ProcessStepManager<OrderDomainEntity>> doProcess(
OrderDomainEntity currentDomainEntity,
ProcessStepManagerUtil<OrderDomainEntity> stepManager,
String idempotencyKey
) {
(3)
return this
.userService
.getUserDetails(currentDomainEntity.getUsername())
.map(userData -> {
currentDomainEntity.setTel(userData.getPhoneNumber());
currentDomainEntity.setEmail(userData.getEmail());
currentDomainEntity.setAddress(userData.getEmail());
return stepManager.next(ReactiveInitializeOrderExecutor.class, "FETCHED_USER_DETAILS");
})
.onErrorResume(throwable -> {
if (throwable instanceof ResourceUnavailableException) {
return Mono.error(RetryableExecutorException.of(throwable));
} else {
return Mono.error(NonRetryableExecutorException.buildWith(throwable)
.put("time", String.valueOf(System.currentTimeMillis()))
.put("reason", "BadRequest")
.build());
}
});
}
}
| 1 | The executor implements the ReactiveQueryExecutor<OrderDomainEntity> interface to make it as a reactive query executor. the generic type should be the domain-entity class that you want to use in the executor. |
||
| 2 | The doProcess method is overridden to implement the primary execution logic in a reactive way. |
||
| 3 | The primary execution logic is implemented inside the doProcess method by using reactive pipeline.
|
Sub Executors
There is a special type of executor called sub executor in Stacksaga other than the query executor and command executor. It’s used for executing the extra compensating atomic transactions in addition to the main compensating transactions.
You already know that you can only execute one atomic transaction inside the executor. The rule is applied for both primary execution and compensating execution. Sometimes You might want to execute another extra execution when one of compensating executions is executed.
For instance, just imagine that the system has a requirement that should be updated to another service when that the order is cancelled execution. Then, as per the executor’s rule, you cannot implement both executions in the doRevert method for canceling the order and notifying that into another server. Because those are totally two atomic operations. In this kind of situation, you can use a sub executor to overcome the challenge. Based on the position that the sub execution should be executed, the sub executors are divided into two types.
-
sub-before-executors
-
If the sub executor should be run before making the main compensating transaction, it can be used sub before Executors. As per the requirement, it can be added any number of sub-before-executors into a command executor. You can navigate the SEC to each of them one by one. See the code implementation.
-
-
sub-after-executors
-
If the sub executor should be run after making the main compensating transaction, it can be used a sub after Executor. As per the requirement, it can be added any number of sub-after-executors into a command executor. You can navigate the SEC to each of them one by one. See the code implementation.
-
If it’s needed to have both sub-before-executors and also sub after Executors, it is possible to do. If it has been configured both before and after executors, the order of the entire compensating transaction is like below.
At 1st all sub before Executors will be executed that you configured into the command executor and after completing the sub before Executors, next it is executed the default compensating execution (main compensating) of the command executor. After completing the main compensating, next it’s executed the sub after Executors that you have configured into the command executor. The diagram shows the order and relationship between the sub-before-executor, and main-revert-execution and sub-after-executor.
Blocking Sub-Before-Executor
@SagaExecutor(executeFor = "order-service", value = "orderInitializeSubBeforeExecutor") (1)
@AllArgsConstructor
public class OrderInitializeSubBeforeExecutor implements RevertBeforeExecutor<OrderDomainEntity, InitializeOrderExecutor> { (2)
private final OrderService orderService;
@Override (3)
public RevertBeforeStepManager<OrderDomainEntity, InitializeOrderExecutor> doProcess(
OrderDomainEntity finalDomainEntityState,
NonRetryableExecutorException nonRetryableExecutorException,
RevertHintStore revertHintStore,
RevertBeforeStepManagerUtil<OrderDomainEntity, InitializeOrderExecutor> stepManager,
String idempotencyKey)
throws RetryableExecutorException {
try {
this.orderService.preCancelOrder(finalDomainEntityState.getOrderId(), idempotencyKey);
return stepManager.complete("REVERTED_ORDER_INITIALIZATION_SUB_BEFORE"); (4)
} catch (OperationAlreadyExecutedException alreadyExecutedException) {
// Return success if already executed on a previous retry attempt
return stepManager.complete("REVERTED_ORDER_INITIALIZATION_SUB_BEFORE");
} catch (FeignException.ServiceUnavailable unavailableException) {
throw RetryableExecutorException.of(unavailableException);
}
}
}
| 1 | The executor is annotated with @SagaExecutor to make it as a spring bean and also to provide the necessary metadata such as the target service that the executor is executed for and the unique name of the executor. |
| 2 | The executor implements the RevertBeforeExecutor<A, C> interface to make it as a sub-before-executor.it should provide the domain-entity class that is used in the entire transaction as the first generic parameter and the command executor class that should be executed before its main compensating execution as the second generic parameter. |
| 3 | The doProcess method is overridden to implement the sub-before-execution logic as the same way as the command executor’s doRevert method. It receives the idempotencyKey to forward to downstream services, and catches duplicate execution exceptions to return success. |
| 4 | if the execution is done successfully, The next sub-before-executor is navigated to the main(parent) doRevert method by using the stepManager.complete method.it should be provided the event name supplier that will be used to store the event name in the event-store regarding the successful execution of the current sub-before-executor. If you have more than one sub-before-executor, you can navigate to the next sub-before-executor by using the stepManager.next method. |
Non-Blocking Sub-Before-Executor
@SagaExecutor(executeFor = "order-service", value = "reactiveRevertBeforeExecutor") (1)
@RequiredArgsConstructor
public class ReactiveOrderInitializeSubBeforeExecutor implements ReactiveRevertBeforeExecutor<OrderDomainEntity, ReactiveInitializeOrderExecutor> { (2)
private final ReactiveOrderService reactiveOrderService;
@Override (3)
@NonNull
public Mono<RevertBeforeStepManager<OrderDomainEntity, ReactiveInitializeOrderExecutor>> doProcess(
OrderDomainEntity domainEntity,
NonRetryableExecutorException processException,
RevertHintStore revertHintStore,
RevertBeforeStepManagerUtil<OrderDomainEntity, ReactiveInitializeOrderExecutor> stepManager,
String idempotencyKey
) {
(4)
return this.reactiveOrderService
.doSomething(idempotencyKey)
.thenReturn(stepManager.complete("REVERTED_ORDER_INITIALIZATION_SUB_BEFORE"))
.onErrorResume(throwable -> {
if (throwable instanceof OperationAlreadyExecutedException) {
return Mono.just(stepManager.complete("REVERTED_ORDER_INITIALIZATION_SUB_BEFORE"));
} else if (throwable instanceof ResourceUnavailableException) {
return Mono.error(RetryableExecutorException.of(throwable));
} else {
revertHintStore.put("ReactiveOrderInitializeSubBeforeExecutor:FAILED", processException.getMessage());
return Mono.just(stepManager.complete("IGNORED"));
}
});
}
}
| 1 | The executor is annotated with @SagaExecutor to make it as a spring bean and also to provide the necessary metadata such as the target service that the executor is executed for and the unique name of the executor. |
||
| 2 | The executor implements the ReactiveRevertBeforeExecutor<A, C> interface to make it as a reactive sub-before-executor.
Generic A is the domain-entity class that is used in the entire transaction and generic C is the command executor class that should be executed before its main compensating execution. |
||
| 3 | The doProcess method is overridden to implement the sub-before-execution logic in a reactive way. |
||
| 4 | The sub-before-execution logic is implemented inside the doProcess method by using reactive pipeline.
If the process is done successfully, and there is no more sub-before-executor for the respective command executor, it can be navigated to the main (parent) |
Blocking Sub-After-Executor
@SagaExecutor(executeFor = "order-service", value = "orderInitializeSubAfterExecutor") (1)
@AllArgsConstructor
public class OrderInitializeSubAfterExecutor implements RevertAfterExecutor<OrderDomainEntity, InitializeOrderExecutor> { (2)
private final OrderService orderService;
@Override (3)
public RevertAfterStepManager<OrderDomainEntity, InitializeOrderExecutor> doProcess(
OrderDomainEntity finalDomainEntityState,
NonRetryableExecutorException processException,
RevertHintStore revertHintStore,
RevertAfterStepManagerUtil<OrderDomainEntity, InitializeOrderExecutor> stepManager,
String idempotencyKey
) throws RetryableExecutorException {
try {
this.orderService.postCancelOrder(finalDomainEntityState.getOrderId(), idempotencyKey);
return stepManager.complete("REVERTED_ORDER_INITIALIZATION_SUB_AFTER"); (4)
} catch (OperationAlreadyExecutedException alreadyExecutedException) {
// Return success if already executed on a previous retry attempt
return stepManager.complete("REVERTED_ORDER_INITIALIZATION_SUB_AFTER");
} catch (FeignException.ServiceUnavailable unavailableException) {
throw RetryableExecutorException.of(unavailableException);
}
}
}
| 1 | The executor is annotated with @SagaExecutor to make it as a spring bean and also to provide the necessary metadata such as the target service that the executor is executed for and the unique name of the executor. |
| 2 | The executor implements the RevertAfterExecutor<A, C> interface to make it as a sub-after-executor.it should provide the domain-entity class that is used in the entire transaction as the first generic parameter and the command executor class that should be executed after its main compensating execution as the second generic parameter. |
| 3 | The doProcess method is overridden to implement the sub-after-execution logic as the same way as the command executor’s doRevert method. |
| 4 | if the execution is done successfully, The next sub-after-executor is navigated by using the stepManager.complete method.it should be provided the event name supplier that will be used to store the event name in the event-store regarding the successful execution of the current sub-after-executor. If you have more than one sub-after-executor, you can navigate to the next sub-after-executor by using the stepManager.next method. |
Non-Blocking Sub-After-Executor
@SagaExecutor(executeFor = "order-service", value = "reactiveOrderInitializeSubAfterExecutor") (1)
public class ReactiveOrderInitializeSubAfterExecutor implements ReactiveRevertAfterExecutor<OrderDomainEntity, ReactiveInitializeOrderExecutor> { (2)
private final ReactiveOrderService reactiveOrderService;
@Override
@NonNull (3)
public Mono<RevertAfterStepManager<OrderDomainEntity, ReactiveInitializeOrderExecutor>> doProcess(
OrderDomainEntity finalDomainEntityState,
NonRetryableExecutorException processException,
RevertHintStore revertHintStore,
RevertAfterStepManagerUtil<OrderDomainEntity, ReactiveInitializeOrderExecutor> stepManager,
String idempotencyKey
) {
(4)
return this.reactiveOrderService
.doSomething(idempotencyKey)
.thenReturn(stepManager.complete("EXECUTED_ORDER_INITIALIZATION_SUB_AFTER"))
.onErrorResume(throwable -> {
if (throwable instanceof OperationAlreadyExecutedException) {
return Mono.just(stepManager.complete("EXECUTED_ORDER_INITIALIZATION_SUB_AFTER"));
} else if (throwable instanceof ResourceUnavailableException) {
return Mono.error(RetryableExecutorException.of(throwable));
} else {
revertHintStore.put("ReactiveOrderInitializeSubAfterExecutor:FAILED", processException.getMessage());
return Mono.just(stepManager.complete("IGNORED"));
}
});
}
}
| 1 | The executor is annotated with @SagaExecutor to make it as a spring bean and also to provide the necessary metadata such as the target service that the executor is executed for and the unique name of the executor. |
||
| 2 | The executor implements the ReactiveRevertAfterExecutor<A, C> interface to make it as a reactive sub-before-executor.
Generic A is the domain-entity class that is used in the entire transaction and generic C is the command executor class that should be executed before its main compensating execution. |
||
| 3 | The doProcess method is overridden to implement the sub-after-execution logic in a reactive way. |
||
| 4 | The sub-after-execution logic is implemented inside the doProcess method by using reactive pipeline.
If the process is done successfully, and there is no more sub-before-executor for the respective command executor, it can be navigated to the main (parent) |
Summary
Retryable Executor Exceptions are allowed for the following executors.
| Executor | DoProcess() Method | doRevert() Method |
|---|---|---|
Query Executor |
✔ |
✔ |
Command Executor |
✔ |
✔ |
Revert Before Executor |
✔ |
|
Revert After Executor |
✔ |
Non-Retryable Executor Exceptions are allowed for the following executors.
| Executor | DoProcess() Method | doRevert() Method |
|---|---|---|
Query Executor |
✔ |
✖ |
Command Executor |
✔ |
✖ |
Revert Before Executor |
✖ |
|
Revert After Executor |
✖ |
Guidelines for Creating Executors
Each saga executor should encapsulate a single atomic transaction. This means you must not implement multiple atomic transactions within the same executor.
The primary reason for this restriction is that the executor acts as a retryable unit managed by the Saga Orchestration Engine (SEC). If an executor contains multiple atomic transactions and a failure occurs, the SEC cannot determine which specific transaction failed. For example, if an executor performs three atomic transactions and the third one fails, retrying the executor will re-execute the first and second transactions, potentially leading to duplicate operations if those steps are not idempotent.
This approach can result in data anomalies such as redundancy, integrity violations, and consistency issues. Additionally, if a compensating (rollback) action is required, the SEC lacks the granularity to identify which atomic transaction needs to be reverted, since it treats the executor as a single atomic unit.
Executions Classifying Tips
When you are creating the executors, you have to decide that whether the executor is a command-executor or query-executor. To determine that the following chart will be helpful (from the database prospective).
The summary of the chart is that if the atomic execution is a read-only one, it should be implemented in a query-executor, and if the atomic operation does some state change on any database, that atomic operation should be implemented in a command-executor.
| Operation | Has a Revert | Executor Type |
|---|---|---|
C - Create |
YES |
Command-Executor |
R - Read |
NO |
Query-Executor |
U - Update |
YES |
Command-Executor |
D - delete |
YES |
Command-Executor |
For instance, let’s classify the executions that we have in our placing-order example.
| Execution | Executor Type | Reason |
|---|---|---|
Collecting user’s delivery details |
Query-Executor |
Fetching data doesn’t make an impact on the user-service’s database. |
Initialize the order |
Command-Executor |
The order should be canceled if any upcoming atomic transaction is failed after initializing the order. |
Making Pre-Auth |
Command-Executor |
The Pre-Auth should be canceled if any upcoming atomic transaction is failed after making the Pre-Auth. |
Updating The Stock |
Command-Executor |
The Stock should be restored if any upcoming atomic transaction is failed after reducing the stock. |
Making Real Payment |
Command-Executor |
The Payment should be refunded if any upcoming atomic transaction is failed after making the payment. |
| In the placing-order example, there is no any atomic operation after making the payment. But as the theory, making payment execution should be executed withing a command-executor. because, for instance, if a new another atomic process is added in the future after making the payment, you must implement the compensating execution for making the payment. |
Combine multiple atomic executions
|
There are two possibilities to implement multiple atomic operations in the saga executor.
You know already there are two types of atomic executions in Stacksaga called command executions and query executions.
The query executions can sometimes be used together in the same executor based on the use case. |
Using multiple read-only atomic operations inside the same executor can reduce the event sourcing overhead.
Because you know that after each executor, the new state of the domain-entity is stored as a new event in the database by the Saga engine.
For instance, if you implement 3 read-only atomic transactions in the same executor, you can reduce the event sourcing overhead by 2.
Because if we added those 3 executions to the 3 executors, 3 times the event store is updated after each execution.
First way:
Second way: