How to Order Event Handlers Between a Saga and a Projection

I am looking for some help in understanding how to order event handlers for the same event between a saga and a regular event handler. As I understand it based on this post, as long as the event handlers are in the same processing group then the @Order annotation will apply, or at least it should.

In my project I am using some command side projections to be able to look up the state of a Business entity as it relates to a LoyaltyBank. I explained the project’s high level entities in more detail here:

If you’d like to see more of the code that I will reference you can find the project here.

With all that context out of the way, here’s the core of the issue I am facing. I have a Saga called BusinessDeletionSaga that essentially cleans up all the LoyaltyBanks in the command projection that belonged to that Business. Here is what the Saga looks like:

@Saga
@ProcessingGroup(COMMAND_PROJECTION_GROUP)
@Order(1)
public class BusinessDeletionSaga implements Serializable {

    @Autowired
    private transient CommandGateway commandGateway;
    @Autowired
    private transient EventGateway eventGateway;
    @Autowired
    private transient LoyaltyBankLookupRepository loyaltyBankLookupRepository;

    private static final Logger LOGGER = LoggerFactory.getLogger(BusinessDeletionSaga.class);

    @SagaEventHandler(associationProperty = "businessId")
    @StartSaga
    public void handle(BusinessDeletedEvent event) {
        String businessId = event.getBusinessId();
        SagaLifecycle.associateWith("businessId", businessId);

        Marker marker = Markers.append(REQUEST_ID, event.getRequestId());
        LOGGER.info(marker, "{} received, checking for loyaltyBankEntities", event.getClass().getSimpleName());

        List<LoyaltyBankLookupEntity> loyaltyBankLookupEntities = loyaltyBankLookupRepository.findByBusinessId(businessId);

        if (loyaltyBankLookupEntities.isEmpty()) {
            LOGGER.info(marker, "No loyaltyBankEntities found, ending saga");
            SagaLifecycle.end();
            return;
        }

        loyaltyBankLookupEntities.forEach(
                loyaltyBankLookupEntity -> {
                    LoyaltyBankDeletionStartedEvent loyaltyBankDeletionEvent = LoyaltyBankDeletionStartedEvent.builder()
                            .requestId(event.getRequestId())
                            .loyaltyBankId(loyaltyBankLookupEntity.getLoyaltyBankId())
                            .build();

                    LOGGER.info(
                            MarkerGenerator.generateMarker(loyaltyBankDeletionEvent),
                            "Publishing {} loyaltyBankDeletionEvent",
                            loyaltyBankDeletionEvent.getClass().getSimpleName()
                    );

                    eventGateway.publish(loyaltyBankDeletionEvent);
                }
        );
    }

    @SagaEventHandler(associationProperty = "businessId")
    public void handle(LoyaltyBankDeletedEvent event) {
        Marker marker = Markers.append(REQUEST_ID, event.getRequestId());
        LOGGER.info(marker, "{} received, checking for loyaltyBankEntities", event.getClass().getSimpleName());

        List<LoyaltyBankLookupEntity> loyaltyBankLookupEntities = loyaltyBankLookupRepository.findByBusinessId(event.getBusinessId());

        if (loyaltyBankLookupEntities.isEmpty()) {
            LOGGER.info(marker, "No loyaltyBankEntities found, ending saga");
            SagaLifecycle.end();
            return;
        }

        LOGGER.info(
                marker, "{} loyaltyBankEntities remaining, continuing saga lifecycle",
                loyaltyBankLookupEntities.size()
        );
    }

As you can see the Saga has the COMMAND_PROJECTION_GROUP processing group which has the value "command-projection-group". This group is the same as what I have for the following set of event handlers that create my command side Business projection:

@Component
@Validated
@ProcessingGroup(COMMAND_PROJECTION_GROUP)
@Order(2)
public class BusinessLookupEventsHandler {

    private final BusinessLookupRepository businessLookupRepository;
    private final SmartValidator validator;
    private Marker marker = null;

    public BusinessLookupEventsHandler(BusinessLookupRepository businessLookupRepository, SmartValidator validator) {
        this.businessLookupRepository = businessLookupRepository;
        this.validator = validator;
    }

    private static final Logger LOGGER = LoggerFactory.getLogger(BusinessLookupEventsHandler.class);

    @ExceptionHandler(resultType = IllegalArgumentException.class)
    public void handle(IllegalArgumentException exception) {
        LOGGER.error(marker, exception.getLocalizedMessage());
    }

    @ExceptionHandler(resultType = IllegalProjectionStateException.class)
    public void handle(IllegalProjectionStateException exception) {
        LOGGER.error(marker, exception.getLocalizedMessage());
    }

    @EventHandler
    public void on(BusinessEnrolledEvent event) {
        marker = Markers.append(REQUEST_ID, event.getRequestId());

        BusinessLookupEntity businessLookupEntity = new BusinessLookupEntity(event.getBusinessId());

        marker.add(MarkerGenerator.generateMarker(businessLookupEntity));

        validateEntity(businessLookupEntity);
        businessLookupRepository.save(businessLookupEntity);

        LOGGER.info(marker, BUSINESS_SAVED_IN_LOOKUP_DB, event.getBusinessId());
    }

    @EventHandler
    public void on(BusinessNameChangedEvent event) {
        marker = Markers.append(REQUEST_ID, event.getRequestId());

        BusinessLookupEntity businessLookupEntity = businessLookupRepository.findByBusinessId(event.getBusinessId());
        throwExceptionIfEntityDoesNotExist(businessLookupEntity, String.format(BUSINESS_WITH_ID_DOES_NOT_EXIST, event.getBusinessId()));
        BeanUtils.copyProperties(event, businessLookupEntity);

        marker.add(MarkerGenerator.generateMarker(businessLookupEntity));

        validateEntity(businessLookupEntity);
        businessLookupRepository.save(businessLookupEntity);

        LOGGER.info(marker, BUSINESS_UPDATED_IN_LOOKUP_DB, event.getBusinessId());
    }

    @EventHandler
    public void on(BusinessDeletedEvent event) {
        marker = Markers.append(REQUEST_ID, event.getRequestId());

        BusinessLookupEntity businessLookupEntity = businessLookupRepository.findByBusinessId(event.getBusinessId());
        throwExceptionIfEntityDoesNotExist(businessLookupEntity, String.format(BUSINESS_WITH_ID_DOES_NOT_EXIST, event.getBusinessId()));

        marker.add(MarkerGenerator.generateMarker(businessLookupEntity));

        businessLookupRepository.delete(businessLookupEntity);

        LOGGER.info(marker, BUSINESS_DELETED_FROM_LOOKUP_DB, event.getBusinessId());
    }

    private void validateEntity(BusinessLookupEntity entity) {
        BindingResult bindingResult = new BeanPropertyBindingResult(entity, "businessLookupEntity");
        validator.validate(entity, bindingResult);

        if (bindingResult.hasErrors()) {
            throw new IllegalProjectionStateException(bindingResult.getFieldError().getDefaultMessage());
        }
    }
}

As I mentioned before, I would expect the saga event handler to trigger before the BusinessLookupEventsHandler class. This is not what I am seeing though as my saga is being executed after the BusinessLookupEventsHandler runs.

Just in case it matters, here is my Axon Configuration class:

@Configuration
public class AxonConfig {

    @Bean
    public SnapshotTriggerDefinition accountSnapshotTrigger(Snapshotter snapshotter) {
        return new EventCountSnapshotTriggerDefinition(snapshotter, 10);
    }

    @Autowired
    public void registerAccountCommandInterceptors(ApplicationContext context, CommandBus commandBus) {
        commandBus.registerDispatchInterceptor(
                context.getBean(ValidateCommandInterceptor.class)
        );
        commandBus.registerDispatchInterceptor(
                context.getBean(AccountCommandsInterceptor.class)
        );
        commandBus.registerDispatchInterceptor(
                context.getBean(BusinessCommandsInterceptor.class)
        );
        commandBus.registerDispatchInterceptor(
                context.getBean(LoyaltyBankCommandsInterceptor.class)
        );
        commandBus.registerDispatchInterceptor(
                context.getBean(TransactionCommandsInterceptor.class)
        );
    }

    @Autowired
    public void configure(EventProcessingConfigurer configurer) {
        configurer.registerListenerInvocationErrorHandler(COMMAND_PROJECTION_GROUP,
                configuration -> new LoyaltyServiceEventsErrorHandler());
        configurer.registerListenerInvocationErrorHandler(REDEMPTION_TRACKER_GROUP,
                configuration -> new LoyaltyServiceEventsErrorHandler());
        configurer.registerListenerInvocationErrorHandler(EXPIRATION_TRACKER_GROUP,
                configuration -> new LoyaltyServiceEventsErrorHandler());
    }
}

At present this ordering doesn’t present any issues, but there is a new feature that I would like to implement that will require the saga to be able to look up the BusinessLookupEntity that is being deleted in the projection class.

I have had the idea to issue a secondary event after the saga is finished to then trigger the projection class, or to just delete the BusinessLookupEntity inside the saga, but I think it would be nice to have everything pertaining to management of the projection’s state in the same class, and not have to issue a secondary event.

(If you’ve made it this far, thank you :grinning:)

So this leads to my question, is there a way to order event handlers between a Saga and a Projection? From what I’ve gathered from the documentation and this forum; a Processing Group has multiple Event Handlers within it, and you can order them with the @Order annotation. I am not sure if it is the different “flavors” that are causing the problem here. Where a saga’s event handlers are different than a regular one.

‘Event handlers, or Event Handling Components, come in roughly two flavors: “regular” (singleton, stateless) event handlers and sagas
- Axon Docs

I will try and play around with the Event Handler to Processing Group functions in the Event Handler Assignment Rules part of the documentation to see if these can provide more fine grained control for ordering, but thought I should reach out in case anyone else has run into this in their projects.

As always, thank you so much for your time and consideration, and I look forward to the discussion that follows!

Hi Alex,

I believe you can only apply the @Order annotation on an event handler level.

/Marc

Thanks for the reply @Marc_Klefter,

Just to clarify, are you saying that I need to put the @Order annotation on the event handler method?

From the docs, I thought that @Order was used to determine the order of how the event handling component is assigned to a processing group, and that the order of event handling for the same event in a processing group is based on the order in which each component was assigned.

“If we use Spring as the mechanism for wiring everything, we can explicitly specify the event handler component ordering by adding the @Order annotation. This annotation is placed on the event handler class name, containing an integer value to specify the ordering.”
- Ordering event handlers within a processor

Or are you saying that the @Order annotation doesn’t apply to Sagas, because I am a little confused there as well. Looking through the code I can see that a Saga extends an EventMessageHandler. So I thought that it would apply, but I must be missing something.

Hi Alex,

I was a bit unclear in my response - ordering within the same processing group using @Order is only possible using @EventHandlers, thus it doesn’t apply to @SagaEventHandlers.

So I’ve done a little more digging and found a few more pieces in the docs that I am hoping to get clarification on. In the docs for implementing a Saga it says:

“Sagas are managed by a single event processor (Tracking or Subscribing), which is dedicated to dealing with events for that specific saga type.”
- Implementation

So this had me wondering if the SagaManager creates it’s own event processor, and therefore even if you use the @ProcessingGroup annotation, the processing group for the saga would go to a different event processor than the Event Handling Component.

If I am understanding this correctly (which I very well may not), I think that my event handler configuration would look something like the following:

I am assuming this is because of how the Event Processor for a Saga must be different than a normal event processor, in that it needs to find an association property to determine which instance(s) of a Saga should handle an event. As such, you can’t really put them in the same Event Processor, and therefore you cannot order them.

Am I understanding this correctly?

If so, I would also like to clarify that there is no way to use any of the “assignment rules” mentioned in this note to be able to configure both handlers in the same processing group?

‘As a Saga is a type of event handler, it is part of an Event Processor. Without defining any assignment rules, a Saga’s processor name equals the Saga name appended with “Processor”’
- Configuring a Saga

Thanks for the clarification.

Is what I’ve written in the other reply correct as to the reason why this is the case?

Essentially yes - a Saga is associated with a “special type” of event processor in terms of looking up Saga instances for a given event and load-balancing Saga execution.

Thank you so much Marc,

My final question is, do you know (or anyone else) if there is a way to access the “special type” of event processor to add the other event handling component to it and the processing group created for the saga?

I tried adding the following to my AxonConfig:

    @Autowired
    public void configure(EventProcessingConfigurer configurer) {
        configurer.assignHandlerInstancesMatching(BUSINESS_PROJECTION_GROUP, handler -> {
            boolean matches = handler instanceof BusinessDeletionSaga;
            if (matches) {
                logger.info("BusinessDeletionSaga assigned to business-projection-group");
            }
            return matches;
        });

        configurer.assignHandlerInstancesMatching(BUSINESS_PROJECTION_GROUP, handler -> {
            boolean matches = handler instanceof BusinessLookupEventsHandler;
            if (matches) {
                logger.info("BusinessLookupEventsHandler assigned to business-projection-group");
            }
            return matches;
        });

        configurer.assignProcessingGroup(BUSINESS_PROJECTION_GROUP, "BusinessDeletionSagaProcessor");
    }

Which only generated a log statement for the BusinessLookupEventsHandler. I may have been going about it wrong, but it doesn’t look like the SagaEventHandlers follow the same initialization process based on this test.

At this point I am just really curious if someone could explain (or point me to the area in the Axon Framework code base) that handles the creation of these SagaEventHandlers and how they are configured in the “special type” of event processor.

It still seems to me that there would be a way to make sure the Saga handles the event before the projection, just may not be that straight forward.

Hi Alex,

Unfortunately setting up the ordering of regular event handlers vs Saga event handlers in the same processing group is not possible - there is no “coordination” between the SagaManager (that wraps and manages Saga event handlers) and other regular event handlers.

In general, I’d advise against relying on any event handler ordering at all and instead make it explicit in your business flow.