Aggregator KeyGen Custom Implementation

Overview

AggregatorKeyGenerator is an interface that allows you to define a custom strategy for generating unique identifiers for saga transactions. the interface provides two methods for generating keys:

  • generateTransactionKey - generates a unique identifier for a new saga transaction.

  • generateIdempotencyKey - generates an idempotency key for each span of the transaction.

Generating Unique Identifiers for Saga Transactions

Every saga transaction requires a globally unique identifier To supply a custom key generator for an Aggregator. implement the AggregatorKeyGenerator interface. Its generateKey method provides rich context you can leverage to construct a deterministic or randomized identifier: serviceName, serviceVersion, instanceId, region, zone,executionMode, and the sagaAggregator metadata.

Providing a custom KeyGen is optional. StackSaga ships with a production-ready default (DefaultKeyGen.class). A custom generator can still be valuable to:

  • align identifiers with your sharding/partitioning strategy,

  • improve index locality or read/write patterns,

  • embed minimal routing or observability hints,

  • satisfy organization-specific compliance or traceability rules.

The default generator produces time-ordered, high-entropy identifiers using this shape:

  • <serviceInitials>-<epochMillis>-<nanoId>

For example, with a service named order-service, default IDs might look like:

  • OS-1713809175237-021575259417101

  • OS-1713809468378-117401549843120

  • OS-1713809493499-012220401009440

jnanoid is used to generate the random NanoID segment, providing excellent entropy and collision resistance.

Generating Idempotency Keys for Saga Spans

Each span of a saga transaction, (i.e., each executor invocation) requires an idempotency key to ensure safe retries. The generateIdempotencyKey method provides context including serviceName, serviceVersion, instanceId, region, zone, currentExecutor, transactionId, executionMode, and a hashGenerator utility.

it is highly recommended to use the provided hashGenerator to produce a fixed-length hash of a composite string that includes the transactionId, currentExecutor, and executionMode. This approach ensures idempotency keys are compact, consistent in length, and collision-resistant.

Custom implementation of AggregatorKeyGenerator

The implementation is as follows.

Due to the fact that the AggregatorKeyGenerator’s all methods are `default methods, no need to implement all methods. you can implement only the required methods as needed.
@Component
public class PlaceOrderAggregatorKeyGenerator implements AggregatorKeyGenerator {

    @Override
    public String generateKey(String serviceName, String serviceVersion, String instanceId, String region, String zone, SagaAggregator sagaAggregator) {
        StringBuilder regionKey = new StringBuilder();
        for (char c : region.toCharArray()) {
            regionKey.append((int) c);
        }
        return String.format("%s-%s-%s", serviceName , regionKey , UUID.randomUUID());
    }

    @Override
    public String generateIdempotencyKey(String serviceName,
                                         String serviceVersion,
                                         String instanceId,
                                         String region,
                                         String zone,
                                         String currentExecutor,
                                         SagaAggregator sagaAggregator,
                                         String transactionId,
                                         ExecutionMode executionMode,
                                         HashGenerator hashGenerator)
    {
        final String string = new StringJoiner(":")
                .add(transactionId)
                .add(currentExecutor)
                .add(executionMode.name().toLowerCase())
                .toString();
        return hashGenerator.generateHash(string, HashGenerator.ALGType.MD5);
    }
}
Register the implementation as a Spring bean (e.g., @Component) and ensure it is stateless and thread-safe. The framework may invoke it concurrently.

After implementing, wire it into the Aggregator via the keyGen attribute on @SagaAggregator:

@SagaAggregator(
        version = @SagaAggregatorVersion(major = 1, minor = 0, patch = 1),
        name = "PlaceOrderAggregator",
        sagaSerializable = PlaceOrderAggregatorSample.class,
        keyGen = PlaceOrderAggregatorKeyGenerator.class
)
public class PlaceOrderAggregator extends Aggregator  {
    // ...existing code...
}

After configuring the custom keygen to the aggregator that will be invoked by framework when start the transaction. and then the generated ID will be stored to the Aggregator#aggregatorTransactionId. and then it can be accessed by calling the get method from your aggregator class due to that your aggregator class has been extended by the Aggregator.

Accessing the generated identifier:

@RequestMapping("/order-place")
@RestController
public class PlaceOrderController {
    private final SagaTemplate<PlaceOrderAggregator> placeOrderAggregatorSagaTemplate;

    public PlaceOrderController(SagaTemplate<PlaceOrderAggregator> placeOrderAggregatorSagaTemplate) {
        this.placeOrderAggregatorSagaTemplate = placeOrderAggregatorSagaTemplate;
    }

    @PostMapping
    public Object placeOrder() {
        PlaceOrderAggregator placeOrderAggregator = new PlaceOrderAggregator();
        // initialize aggregator payload
        placeOrderAggregator.setUserName("mafei");
        // ...existing code...

        // Before process(): the ID is not yet assigned.
        String aggregatorTransactionIdBefore = placeOrderAggregator.getAggregatorTransactionId();
        System.out.println("aggregatorTransactionIdBefore = " + aggregatorTransactionIdBefore);

        // process(): framework assigns aggregatorTransactionId and persists it in the Aggregator state.
        placeOrderAggregatorSagaTemplate.process(
                placeOrderAggregator,
                InitializeOrderExecutor.class
        );

        // After process(): the ID is available.
        String aggregatorTransactionIdAfter = placeOrderAggregator.getAggregatorTransactionId();
        System.out.println("aggregatorTransactionIdAfter = " + aggregatorTransactionIdAfter);

        return Collections.singletonMap("aggregator_id", aggregatorTransactionIdAfter);
    }
}

Sample output:

aggregatorTransactionIdBefore = null
aggregatorTransactionIdAfter = ORDER-SERVICE-117115-71230b5c-79b5-418b-990b-058fba747869