Configuration

Every worker reply flows back to the orchestrator through a per-domain callback topic, consumed by a Spring for Apache Kafka listener container. How those containers are allocated and tuned — together with the declarative properties and the overridable provider beans — governs the orchestrator’s callback throughput, latency, resource footprint, and fault isolation. This page collects everything you configure on the orchestrator side.

By default, every SHARED_GLOBAL event manager’s callback topic is consumed by a single global callback container, which keeps consumer-thread usage low and suits most services. But a high-volume or latency-sensitive saga can be given its own container so it neither suffers nor causes head-of-line blocking, and the group protocol (groupType) decides whether parallelism is capped by the partition count. Tuning is about matching each event manager’s isolation, protocol, and concurrency to its real load.

This page covers, from the highest-level choice down to the finest:

  1. Callback topic and listener models — the per-domain callback topic, the listenerScope container allocation (SHARED_GLOBAL, SHARED_GROUP, ISOLATED), and the groupType protocol. See Callback Topic and Listener Models.

  2. Configuration properties — the declarative stacksaga.kafka.orchestrator. and stacksaga.instance. settings. See Configuration Properties Of stacksaga-kafka-orchestrator-spring-boot-starter.

  3. Programmatic configuration — replacing the framework’s provider beans (such as a custom ConsumerFactory) when properties alone are not enough. See Programmatic Configuration (Bean Overrides).

Callback Topic and Listener Models

Per-Domain Callback Topic

In stacksaga-kafka-orchestrator, a dedicated topic is created for each EventManager of the DomainEntity to receive the response messages from the Kafka worker endpoints.

The topic name is built as saga.callback.{appName}.{domainCallbackTopicSuffix}, where:

  • {appName} is the application’s service name, lowercased with any non-alphanumeric character collapsed to -.

  • {domainCallbackTopicSuffix} is appended as-is from the @SagaEventManager annotation.

For instance, a SHARED_GLOBAL event manager in the order-service application with domainCallbackTopicSuffix = "place-order" resolves to the real topic saga.callback.order-service.place-order;

the domainCallbackTopicSuffix is configured in the @SagaEventManager annotation of the EventManager as below.

@SagaEventManager(
        domainCallbackTopicSuffix = "place-order"
)

Listener Container Scopes

Even though a dedicated callback topic is created for each EventManager, the container that consumes those callback topics is allocated according to the listenerScope configured in the @SagaEventManager annotation. There are three scopes:

Independently of the scope, each container consumes using the Kafka group protocol chosen with the groupType attribute — GroupType.CONSUMER (classic consumer group, backed by a ConcurrentMessageListenerContainer) or GroupType.SHARE (Kafka 4 share group / KIP-932, queue-style consumption backed by a ShareKafkaMessageListenerContainer, whose parallelism is not capped by the partition count).

A single container speaks only one protocol, so every event manager that shares a container — all SHARED_GLOBAL managers, or all members of a given SHARED_GROUP — must declare the same groupType. A mismatch fails fast at startup. GroupType.SHARE requires a Kafka 4.x broker with share groups enabled.

Whether a container starts automatically on application startup is controlled by the autoStart attribute of SagaEventManagerListener (used via isolatedExecutionListener/sharedGroupExecutionListener; boolean, default true).

A single container has only one lifecycle, so every event manager sharing one — all members of a given SHARED_GROUP — must declare the same autoStart. A mismatch fails fast at startup with a ValidationException. This restriction does not apply to SHARED_GLOBAL, whose auto-start behaviour is instead controlled application-wide by stacksaga.kafka.orchestrator.global-callback-listener.auto-start (see Configuration Properties Of stacksaga-kafka-orchestrator-spring-boot-starter).

Let’s dive into details of each listener scope.

SHARED_GLOBAL

If the @SagaEventManager is configured with listenerScope = OrchestratorListenerScope.SHARED_GLOBAL, the framework binds that event manager’s callback topic to a single, global callback listener container shared by all SHARED_GLOBAL event managers. This is the default and the right choice for most sagas: it pools every callback topic into one container instead of spinning up a container per orchestrator, keeping consumer-thread usage low.

Here is the architecture for the shared listener model in stacksaga-kafka-orchestrator.

Shared listener container in stacksaga kafka orchestrator
  • The group protocol and concurrency of the global container are configured application-wide via stacksaga.kafka.orchestrator.global-callback-listener.group-type and stacksaga.kafka.orchestrator.global-callback-listener.concurrency (see Configuration Properties Of stacksaga-kafka-orchestrator-spring-boot-starter). The default group protocol is SHARE.

  • The consumer group follows the convention saga-os-{serviceName}-… where os stands for Orchestration Service.

  • For GroupType.CONSUMER, the listener is configured with auto.offset.reset=earliest, so previously published messages can be consumed after a service restart when no committed offset is available. For GroupType.SHARE, this property does not apply — share consumers manage offsets at the broker (KIP-932) rather than via the classic per-consumer auto.offset.reset, and the framework does not currently configure the share-group equivalent (share.auto.offset.reset).

  • Regardless of groupType, the container acknowledges/commits a reply record as soon as its listener callback returns — EventPreparationManager.prepare() resolves every outcome (successful step advancement, a paused/retryable transaction, or a terminated transaction) internally rather than letting an exception propagate to Kafka, so the callback always returns normally. A retryable failure therefore does not by itself cause Kafka to redeliver the reply. With GroupType.SHARE specifically, the container uses Spring’s ShareAckMode.IMPLICIT and never calls the share-group’s RELEASE/REJECT outcomes — the only capability the framework draws from GroupType.SHARE is queue-style concurrency past the partition-count ceiling, not its redelivery semantics.

  • At-Least-Once processing of a saga step is instead guaranteed by the framework’s own retry system, independent of whether Kafka ever redelivers the original reply. When a worker reports RetryableExecutorException, the orchestrator persists the transaction as paused rather than retrying it inline; a completely separate module — the Ring Coordinator — later re-invokes the stalled span on whichever orchestrator instance currently owns that transaction’s token sub-range (see that module’s documentation for the full retry architecture). Genuine Kafka-level redelivery can still occur in its own right (e.g. a process crash before the listener callback returns), which is why the framework still provides a per-span idempotency key on each message. The framework does not discard duplicates automatically — guarding against processing the same reply twice, from either cause, must be handled by idempotent handling logic that uses that key.

SHARED_GROUP

If the @SagaEventManager is configured with listenerScope = OrchestratorListenerScope.SHARED_GROUP, its callback topic is consumed by a container shared only with other event managers that declare the same listener container name. This pools a specific subset of event managers into their own container, separate from the global one, when they benefit from dedicated concurrency or should be isolated from the load of unrelated managers.

The container is configured through the sharedGroupExecutionListener attribute of @SagaEventManager:

@SagaEventManager(
        value = "placeOrderEventManager",
        domainCallbackTopicSuffix = "place-order",
        listenerScope = OrchestratorListenerScope.SHARED_GROUP,
        groupType = GroupType.SHARE,
        sharedGroupExecutionListener = @SagaEventManagerListener(
                listenerContainerName = "order-group", // required: the group key other managers repeat to join
                concurrency = 10,
                autoStart = true
        )
)
  • listenerContainerName is required here — it is the group key that other event managers repeat to share this container. Registration fails if it is left blank.

  • All members must declare the same groupType (validated at startup).

  • All members must declare the same autoStart (validated at startup) — a shared container has a single lifecycle, so a mismatch fails fast with a ValidationException.

  • The container’s concurrency is the maximum concurrency declared across all members of the group.

ISOLATED

If the @SagaEventManager is configured with listenerScope = OrchestratorListenerScope.ISOLATED, the framework creates a dedicated callback listener container for that event manager’s callback topic, consumed by no other manager. Use it for orchestrators with distinct performance or reliability requirements — e.g. a high-throughput or latency-sensitive saga — where sharing a container could cause head-of-line blocking or contention over consumer threads. It provides the best isolation at the cost of higher resource usage.

Here is the architecture for the isolated-listener model in stacksaga-kafka-orchestrator.

Isolated listener container in stacksaga kafka orchestrator

The dedicated container is configured through the isolatedExecutionListener attribute of @SagaEventManager:

@SagaEventManager(
        value = "placeOrderEventManager",
        domainCallbackTopicSuffix = "place-order",
        listenerScope = OrchestratorListenerScope.ISOLATED,
        groupType = GroupType.SHARE,
        isolatedExecutionListener = @SagaEventManagerListener(
                listenerContainerName = "", // optional: defaults to {beanName}ListenerContainer
                concurrency = 5,
                autoStart = true
        )
)
  • listenerContainerName is optional; if left blank, the container is named {beanName}ListenerContainer, where {beanName} is the event manager’s value.

  • concurrency and autoStart come from this @SagaEventManagerListener.

  • The consumer group follows the same saga-os-{serviceName}-… convention, and the same auto.offset.reset=earliest and At-Least-Once semantics as SHARED_GLOBAL.

Configuration Properties Of stacksaga-kafka-orchestrator-spring-boot-starter

Property DataType Default Value Description

stacksaga.kafka.orchestrator.domain-entity-scan

String[]

[]

A comma-separated list of package names to scan for @SagaDomainEntity annotated classes. for instance, com.example.domain,com.example.anotherdomain.

stacksaga.kafka.orchestrator.global-callback-listener.group-type

GroupType

SHARE

The Kafka group protocol used by the global callback listener container that consumes the callback topics of all SHARED_GLOBAL event managers. SHARE uses the Kafka 4 share-group protocol (KIP-932, queue-style consumption); CONSUMER uses the classic consumer-group protocol. See more about the listener models.

stacksaga.kafka.orchestrator.global-callback-listener.concurrency

int

20

The concurrency level of the global callback listener container. With group-type: SHARE, this can be set to any desired value because share groups are not limited by the partition count. With group-type: CONSUMER, it should not exceed the total number of partitions of the callback topics bound to the global container.

stacksaga.kafka.orchestrator.global-callback-listener.auto-start

boolean

true

Whether the global callback listener container that consumes the callback topics of all SHARED_GLOBAL event managers starts automatically on application startup. See [orchestrator_auto_start].

stacksaga.instance.cluster

String

-

The cluster this instance belongs to. Components only connect to one another when their cluster values match, so it must be identical across every Master, Slave, and Orchestrator that needs to connect.

stacksaga.instance.region

String

-

The region this instance belongs to. Components only connect to one another when their region values match, so it must be identical across every Master, Slave, and Orchestrator that needs to connect.

stacksaga.instance.zone

String

-

The zone this instance belongs to. It has no functional impact here, but should be set as per the StackSaga core specification.

Programmatic Configuration (Bean Overrides)

The properties above cover declarative tuning. Beyond them, several StackSaga-Kafka infrastructure components are exposed as overridable Spring beans for advanced, in-code customization. Each component ships with a framework-provided default that is registered with @ConditionalOnMissingBean, so simply declaring your own bean of the same provider type replaces the default — no property flag is required.

This section documents these customization points. The first is the response consumer factory.

Custom Response ConsumerFactory

The orchestrator consumes the workers' reply messages through a ConsumerFactory<String, SagaResponsePayload> supplied by an OrchestratorResponsePayloadConsumerFactoryProvider bean. By default the framework builds this factory from your existing Spring Boot Kafka configuration (spring.kafka.*, via KafkaProperties.buildConsumerProperties()) and then plugs in the two deserializers StackSaga requires — a StringDeserializer for the key and a SagaResponsePayloadDeserializer for the value.

To supply your own factory — for example to tune fetch sizes, security settings, or client properties that are not exposed through spring.kafka.* — extend the abstract OrchestratorResponsePayloadConsumerFactoryProvider, implement consumerFactory(), and register it as a bean:

@Component
public class CustomResponseConsumerFactoryProvider extends OrchestratorResponsePayloadConsumerFactoryProvider {

    private final KafkaProperties kafkaProperties;
    private final SagaResponsePayloadDeserializer sagaResponsePayloadDeserializer; (1)

    public CustomResponseConsumerFactoryProvider(
            KafkaProperties kafkaProperties,
            SagaResponsePayloadDeserializer sagaResponsePayloadDeserializer) {
        this.kafkaProperties = kafkaProperties;
        this.sagaResponsePayloadDeserializer = sagaResponsePayloadDeserializer;
    }

    @Override
    protected ConsumerFactory<String, SagaResponsePayload> consumerFactory() {
        Map<String, Object> props = new HashMap<>(kafkaProperties.buildConsumerProperties()); (2)
        //<3> your custom tuning:
        props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 250);
        props.put(ConsumerConfig.FETCH_MAX_BYTES_CONFIG, 5 * 1024 * 1024);
        return new DefaultKafkaConsumerFactory<>(
                props,
                new StringDeserializer(),            (4)
                sagaResponsePayloadDeserializer      (5)
        );
    }
}
1 Inject the framework’s SagaResponsePayloadDeserializer bean rather than constructing your own — it is already registered as a @Component and wired with the correct JsonMapper.
2 Starting from KafkaProperties.buildConsumerProperties() is optional but recommended, so your spring.kafka.consumer.* settings still apply. You may instead start from an empty map and set every property explicitly.
3 Apply whatever custom consumer tuning you need.
4 The key deserializer must be a StringDeserializer — the message key is the transaction id, sent as a string.
5 The value deserializer must be the SagaResponsePayloadDeserializer — StackSaga’s reply payloads are typed via a message header and can only be reconstructed by this deserializer.

Registering your own OrchestratorResponsePayloadConsumerFactoryProvider bean replaces the framework default entirely (it is @ConditionalOnMissingBean). You must therefore keep the two required deserializers exactly as shown — a different key or value deserializer will break reply deserialization.

Even when you supply a custom factory, the framework still enforces a few settings internally so the required consumption invariants hold. These are applied on top of whatever you configure, so you neither need to set them yourself nor can override them:

When the container’s groupType is… The framework forces…

GroupType.CONSUMER

auto.offset.reset=earliest on the consumer factory.

GroupType.SHARE

The share consumer factory is derived from the one above with the consumer-group-only keys removed — partition.assignment.strategy, enable.auto.commit, auto.commit.interval.ms, auto.offset.reset, and isolation.level (share groups do not use them) — and the share acknowledgement mode forced to implicit (ShareAckMode.IMPLICIT). See the listener models for what these mean.

The same override idiom applies to the other StackSaga-Kafka provider beans on the orchestrator — the producer factory (OrchestratorPayloadProducerFactoryProvider), the KafkaTemplate (OrchestratorKafkaTemplateProvider), and the saga execution scheduler (AbstractSchedulerProvider) — each registered with @ConditionalOnMissingBean and replaceable the same way. Dedicated guides for these follow in later sections.