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 )
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!