Is Using Command Interceptors to Maintain Relationships Between Aggregates an Anti-Pattern?

As I understand it, there have been a few discussions on this topic for quite some time. This is somewhat related to the set validation topic discussed in this post. While I do believe I understand the concept of using the command projections in theory, I am concerned that my implementation is incorrect. You can see the full code for the project here.

Getting into the details of the implementation, I am creating command projections, (AccountLookupEventsHandler, LoyaltyBankLookupEventsHandler) to hold the necessary information I need to make decisions if a command can be processed or not by an aggregate. The associated repositories are then used in an interceptor for an associated command. Taking the CreateAccountCommandInterceptor as an example, you can see that I simply pull the email from the command and then check to see if there is an already existing email in the db and throw an exception if there is one.

@Component
public class CreateAccountCommandInterceptor implements MessageDispatchInterceptor<CommandMessage<?>> {

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

    private final AccountLookupRepository accountLookupRepository;

    public CreateAccountCommandInterceptor(AccountLookupRepository accountLookupRepository) {
        this.accountLookupRepository = accountLookupRepository;
    }

    @Nonnull
    @Override
    public BiFunction<Integer, CommandMessage<?>, CommandMessage<?>> handle(
            @Nonnull List<? extends CommandMessage<?>> messages) {
        return (index, genericCommand) -> {

            if (CreateAccountCommand.class.equals(genericCommand.getPayloadType())) {
                CreateAccountCommand command = (CreateAccountCommand) genericCommand.getPayload();

                String commandName = command.getClass().getSimpleName();
                LOGGER.info(MarkerGenerator.generateMarker(command), INTERCEPTED_COMMAND, commandName);

                String email = command.getEmail();
                AccountLookupEntity accountLookupEntity = accountLookupRepository.findByEmail(email);

                if (accountLookupEntity != null) {
                    Marker marker = MarkerGenerator.generateMarker(accountLookupEntity);
                    marker.add(Markers.append(REQUEST_ID, command.getRequestId()));
                    LOGGER.info(
                            marker,
                            EMAIL_FOUND_ON_ANOTHER_ACCOUNT_CANCELLING_COMMAND,
                            accountLookupEntity.getAccountId(),
                            commandName
                    );

                    throw new EmailExistsForAccountException(email);
                }
            }

            return genericCommand;
        };
    }
}

While this pattern does make sense to me and I really like that the validation is happening before the aggregate processes it, I can’t help but wonder if I am creating unnecessary process here or if this design pattern will have a negative performance impact?

In this example I do already have a uniqueness restriction for the email on the AccountLookupEntity:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "account_lookup")
public class AccountLookupEntity {

    @Id
    @Column(name = "account_id", unique = true)
    private String accountId;
    @Column(name = "email", unique = true)
    private String email;
}

I have this as a fallback in case something went wrong with the interceptor or someone changes the validation sometime in the future. So I believe I could rely on this to do the same validation, but it would be after the aggregate has processed the command and issued the event.

This design also is being used to validate that an account exists when a loyalty bank is being created (there can be multiple loyalty banks for a given account) as shown in the CreateLoyaltyBankCommandInterceptor:

@Component
public class CreateLoyaltyBankCommandInterceptor implements MessageDispatchInterceptor<CommandMessage<?>> {

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

    private final AccountLookupRepository accountLookupRepository;
    private final LoyaltyBankLookupRepository loyaltyBankLookupRepository;

    public CreateLoyaltyBankCommandInterceptor(
            AccountLookupRepository accountLookupRepository, LoyaltyBankLookupRepository loyaltyBankLookupRepository) {
        this.accountLookupRepository = accountLookupRepository;
        this.loyaltyBankLookupRepository = loyaltyBankLookupRepository;
    }

    @Nonnull
    @Override
    public BiFunction<Integer, CommandMessage<?>, CommandMessage<?>> handle(
            @Nonnull List<? extends CommandMessage<?>> messages) {
        return (index, genericCommand) -> {

            if (CreateLoyaltyBankCommand.class.equals(genericCommand.getPayloadType())) {
                CreateLoyaltyBankCommand command = (CreateLoyaltyBankCommand) genericCommand.getPayload();

                String commandName = command.getClass().getSimpleName();
                LOGGER.info(MarkerGenerator.generateMarker(command), INTERCEPTED_COMMAND, commandName);

                String accountId = command.getAccountId();
                AccountLookupEntity accountLookupEntity = accountLookupRepository.findByAccountId(accountId);

                if (accountLookupEntity == null) {
                    LOGGER.info(
                            Markers.append(REQUEST_ID, command.getRequestId()),
                            ACCOUNT_NOT_FOUND_CANCELLING_COMMAND, accountId, commandName
                    );

                    throw new AccountNotFoundException(accountId);
                }

                String businessName = command.getBusinessName();
                List<LoyaltyBankLookupEntity> loyaltyBankLookupEntities = loyaltyBankLookupRepository.findByAccountId(accountId);

                boolean businessAlreadyAssignedToAccount = loyaltyBankLookupEntities.stream()
                        .anyMatch(entity -> entity.getBusinessName().equals(businessName));

                if (businessAlreadyAssignedToAccount) {
                    LOGGER.info(
                            Markers.append(REQUEST_ID, command.getRequestId()),
                            ACCOUNT_ALREADY_ENROLLED_IN_BUSINESS_CANCELLING_COMMAND,
                            accountId, businessName, commandName
                    );

                    throw new AccountExistsWithLoyaltyBankException(accountId, businessName);
                }
            }

            return genericCommand;
        };
    }
}

This time I’m checking if the accountId in the command exists and throwing an exception if it does not. So, not necessarily for set validation, but for if a prerequisite entity is there or not. After that, there is set validation being performed on the businessName.

I think that I might be able to get around this one by changing the aggregate structure such that an account has a list of loyalty banks as aggregate members. That way the CreateLoyaltyBankCommand would be routed and handled by the AccountAggregate instead. Then have to iterate over all existing loyalty banks for an account to validate that the businessName is also unique. (Not sure if there is a “faster” way to do this or not)

Currently when I have everything running in docker on my local machine it takes about 2 seconds for an account to be created, so I am worried that this design is going to have some problems at scale. Granted I am only dedicating 2 CPU cores on a 2015 MacBook pro with an i7 processor to run all 6 containers. You can see more details of that config in the docker-compose.yml in the root of the project.

This is my first time working with Axon on a medium sized project and I am looking for some additional advice on if I am on the right track. Thank you so much for taking the time to read this and I hope the discussion that follows will provide value to others as well.

First and foremost, welcome to the forum, @ajisrael! Perfectly fine to look for some guidance on your first Axon-based application. Heck, feel free to do so on your 10th project I’d say. That’s what the forum is for after all.


Now, regarding your question, is the use of interceptors for set-based validation the right approach?

When reading up on your CreateAccountCommand-approach, I think the interceptor is fine. Although you state that the AccountLookupEntity imposes uniqueness on the email field, for this to impact the original decision to publish the event you would require the event publication and entity update to happen in the same transaction.

Although that could technically be achieved, you would strongly tie together your Event Store and relational database. Although this is maintainable for small-scale applications, I would not advise doing so if the intent is to grow.

Hence, the current solution, with an interceptor validating the set before entering a the specific aggregate for creating an account, seems fine to me.


Now, on to the CreateLoyaltyBankCommand. Without knowing what a loyalty bank actually means in the context of an Account, I do agree that moving the accountId check by making the loyalty bank an entity of the Account is easier.

By doing so, the validation moves to the stream of events identified by the accountId. Given the data consistency imposed by this model, you would have a lot more certainty that the check is the real outcome than a set-based validation can ever give you.

Differently put, if you can avoid set-based validation, I would recommend doing so where feasible.

Although that alleviates the accountId check, it would not resolve the businessName uniqueness. If that’s still mandatory, a similar entity would be required, but then for business names, as with the CreateAccountCommand solution.


So, are you on the right track? I think you are, @ajisrael, so that’s good :slight_smile:

Before ending my reply, I do want to emphasize that a set-based validation that works for the full 100% of the cases would require a consistency model that encompasses the entire set. As having a “single aggregate to rule them all” is a massive bottleneck for any system, going for what’s good enough for validation is your best bet.

What good enough means differs per application, of course.

Concluding, I hope this helps you out, @ajisrael!

1 Like

Thank you so much for your reply @Steven_van_Beelen. I really appreciate the feedback, it’s very helpful. To clarify, the following describes the relationships between the different models (I still have yet to implement the BusinessAccount and all it’s various access controls):

To briefly summarize the core idea of the project, I am trying to create a platform for small businesses to be able to offer loyalty rewards to their customers. The platform will allow users (customers) to have a LoyaltyBank to track their reward points per business they enroll in, and view other businesses local to them that they may want to participate in.

As such, the LoyaltyBank establishes a relationship between an Account and Business. This is part of the reason why I didn’t initially create the LoyaltyBank as an aggregate member of the Account because it really “belongs” to both and I having either the Account or the Business didn’t really feel right to be a third level aggregate member on the LoyaltyBank. Knowing this, do you still think it makes sense to have the LoyaltyBank as an aggregate member on the Account? Also I’ve since removed the requirement for a business name to be unique and have been keeping track of them through an businessId just in case two businesses happen to have the same name.

In general, I really like how the interceptors and command projections work together to “guard” the aggregates from processing an invalid command, my main concern is if there are any negative performance implications or things that I should be looking out for as the project scales, or if I introduce multiple instances of the command API? Does it make sense to persist those projections into the same database as my event store, or should I try and use something that is known to be a little faster like a Redis Cache maybe?

If you or anyone else has any additional insights, that would be greatly appreciated. I must say, working with Axon Framework has been one of, if not the, most enjoyable experiences I’ve had in developing software thus far.

Thank you again for your reply @Steven_van_Beelen, and for all your contributions to this amazing community.

1 Like

Although it is always tough to give a 100% certainty on things, what I do think is important to note is that it is fine for a LoyaltyBank entity to exist in several places. It would just serve a different purpose.

When thinking about CQRS, the “duplication” becomes more apparent, as one entity is in charge of validation (the command model) while the other is in charge of answering questions (the query model). This point does not stop there, though, in my opinion.

You can very well have several command models with their own idea of an entity.

So, concluding: yes, I think there isn’t anything inherently wrong with making a LoyaltyBank aggregate member on the Account.

Any interceptor you add has some impact on the system, that’s for sure. It simply blocks the thread from entering the aggregate.

As such, it is paramount to make the interceptor as efficient as possible. However, I would also avoid too much premature optimization in this area, @ajisrael. Just monitor the command handling process in general, and as soon as you see it surpass a team/company-defined boundary, then go for the optimization.

Although this is a shameless plug, we have built AxonIQ Console with just these kinds of monitoring tools in place. If you are not able or allowed to use AxonIQ Console, you can also include other measurements. Our documentation states some approaches you can take, so perhaps that’s helpful for you when the time comes: Monitoring

Redis Cache for the uniqueness constraint would be a smart move to make I think! But, again, I would start optimizing as soon as it becomes a bottleneck, personally.

Thanks for these kind words, @ajisrael! I can assure you, I will keep helping out in this area, so be sure to see responses from my in the future!

2 Likes

@Steven_van_Beelen, thank you again for the reply. I believe I understood your response conceptually, when I went to implement it though, more questions arose. I am really looking to understand the different design patterns in the Axon ecosystem and make sure that my current design is “good enough” from the perspective of those who have worked in it a long time, as well as understanding the costs and benefits of the different patterns. The “good enough” question was definitely addressed in the previous response (hence why it is marked as the solution), this follow up is focusing more on understanding the cost and benefits of other solutions.

Regarding how to incorporate having the AccountAggregate and BusinessAggregate have their own ideas of a LoyaltyBank entity. If I am incorrectly conflating terms please let me know, but I have understood “command model” to refer to the idea implemented by an aggregate (and associated aggregate members, if any). These models exist for the express purpose of deciding if a newly issued command is valid, as you already stated.

As such, I believe for the respective command models of an account and a business inside of the “Loyalty Application” domain, these would only need to have a set of loyaltyBankIds in order to determine if the commands issued to these aggregates are valid, as these aggregates don’t need to have knowledge of the amount of points in a loyalty bank to handle commands for their respective models. Otherwise I believe I would have to incorporate the handling of all transactions currently managed by the LoyaltyBankAggregate.

For additional context here is my current implementation of a LoyaltyBankAggregate:

@Aggregate
@NoArgsConstructor
@Getter
public class LoyaltyBankAggregate {

    public static final Logger LOGGER = LoggerFactory.getLogger(LoyaltyBankAggregate.class);

    @AggregateIdentifier
    private String loyaltyBankId;
    private String accountId;
    private String businessId;
    private int pending;
    private int earned;
    private int authorized;
    private int captured;


    @CommandHandler
    public LoyaltyBankAggregate(CreateLoyaltyBankCommand command) {
        LoyaltyBankCreatedEvent event = LoyaltyBankCreatedEvent.builder()
                .requestId(command.getRequestId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .accountId(command.getAccountId())
                .businessId(command.getBusinessId())
                .pending(0)
                .earned(0)
                .authorized(0)
                .captured(0)
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(CreatePendingTransactionCommand command) {
        if (this.pending + command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(PENDING);
        }

        PendingTransactionCreatedEvent event = PendingTransactionCreatedEvent.builder()
                .requestId(command.getRequestId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .points(command.getPoints())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(CreateEarnedTransactionCommand command) {
        if (this.pending - command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(PENDING);
        }

        if (this.earned + command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(EARNED);
        }

        EarnedTransactionCreatedEvent event = EarnedTransactionCreatedEvent.builder()
                .requestId(command.getRequestId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .points(command.getPoints())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(CreateAwardedTransactionCommand command) {
        if (this.earned + command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(EARNED);
        }

        AwardedTransactionCreatedEvent event = AwardedTransactionCreatedEvent.builder()
                .requestId(command.getRequestId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .points(command.getPoints())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(CreateAuthorizedTransactionCommand command) {
        if (this.authorized + command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(AUTHORIZED);
        }
        if (this.getAvailablePoints() < command.getPoints()) {
            LOGGER.info(MarkerGenerator.generateMarker(this), INSUFFICIENT_AVAILABLE_POINTS_FOR_AUTHORIZATION, command.getPoints());
            throw new InsufficientPointsException();
        }

        AuthorizedTransactionCreatedEvent event = AuthorizedTransactionCreatedEvent.builder()
                .requestId(command.getRequestId())
                .paymentId(command.getPaymentId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .points(command.getPoints())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(CreateVoidTransactionCommand command) {
        if (this.authorized - command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(AUTHORIZED);
        }

        VoidTransactionCreatedEvent event = VoidTransactionCreatedEvent.builder()
                .requestId(command.getRequestId())
                .paymentId(command.getPaymentId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .points(command.getPoints())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(CreateCapturedTransactionCommand command) {
        if (this.authorized - command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(AUTHORIZED);
        }
        if (this.captured + command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(CAPTURED);
        }

        CapturedTransactionCreatedEvent event = CapturedTransactionCreatedEvent.builder()
                .requestId(command.getRequestId())
                .paymentId(command.getPaymentId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .points(command.getPoints())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(CreateExpirePointsTransactionCommand command) {
        if (this.captured + command.getPoints() < 0) {
            throw new IllegalLoyaltyBankStateException(CAPTURED);
        }

        ExpiredTransactionCreatedEvent event = ExpiredTransactionCreatedEvent.builder()
                .requestId(command.getRequestId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .targetTransactionId(command.getTargetTransactionId())
                .points(command.getPoints())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(ExpireAllPointsCommand command) {
        AllPointsExpiredEvent event = AllPointsExpiredEvent.builder()
                .requestId(command.getRequestId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .accountId(this.accountId)
                .businessId(this.businessId)
                .pendingPointsRemoved(this.pending)
                .authorizedPointsVoided(this.authorized)
                .pointsExpired(this.earned - this.captured)
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(DeleteLoyaltyBankCommand command) {
        throwExceptionIfLoyaltyBankStillHasAvailablePoints();

        LoyaltyBankDeletedEvent event = LoyaltyBankDeletedEvent.builder()
                .requestId(command.getRequestId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .accountId(this.accountId)
                .businessId(this.businessId)
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void on(UnenrollLoyaltyBankCommand command) {
        LoyaltyBankDeletedEvent event = LoyaltyBankDeletedEvent.builder()
                .requestId(command.getRequestId())
                .loyaltyBankId(command.getLoyaltyBankId())
                .accountId(this.accountId)
                .businessId(this.businessId)
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @EventSourcingHandler
    public void on(LoyaltyBankCreatedEvent event) {
        this.loyaltyBankId = event.getLoyaltyBankId();
        this.accountId = event.getAccountId();
        this.businessId = event.getBusinessId();
        this.pending = event.getPending();
        this.earned = event.getEarned();
        this.authorized = event.getAuthorized();
        this.captured = event.getCaptured();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(PendingTransactionCreatedEvent event) {
        this.pending += event.getPoints();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(EarnedTransactionCreatedEvent event) {
        this.pending -= event.getPoints();
        this.earned += event.getPoints();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(AwardedTransactionCreatedEvent event) {
        this.earned += event.getPoints();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(AuthorizedTransactionCreatedEvent event) {
        this.authorized += event.getPoints();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(VoidTransactionCreatedEvent event) {
        this.authorized -= event.getPoints();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(CapturedTransactionCreatedEvent event) {
        this.authorized -= event.getPoints();
        this.captured += event.getPoints();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(ExpiredTransactionCreatedEvent event) {
        this.captured += event.getPoints();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(AllPointsExpiredEvent event) {
        this.pending -= event.getPendingPointsRemoved();
        this.authorized -= event.getAuthorizedPointsVoided();
        this.captured += event.getPointsExpired();

        // Should never throw
        throwExceptionIfLoyaltyBankStillHasAvailablePoints();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(LoyaltyBankDeletedEvent event) {
        AggregateLifecycle.markDeleted();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    protected int getAvailablePoints() {
        return this.earned - this.authorized - this.captured;
    }

    private void throwExceptionIfLoyaltyBankStillHasAvailablePoints() {
        if (this.pending != 0 || this.authorized != 0 || this.earned != this.captured) {
            throw new FailedToExpireLoyaltyPointsException(this.loyaltyBankId);
        }
    }
}

Here is the current implementation of an AccountAggregate:

@Aggregate
@NoArgsConstructor
@Getter
public class AccountAggregate {

    @Autowired
    private CommandGateway commandGateway;

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

    @AggregateIdentifier
    private String accountId;
    private String firstName;
    private String lastName;
    private String email;

    @CommandHandler
    public AccountAggregate(CreateAccountCommand command) {
        AccountCreatedEvent event = AccountCreatedEvent.builder()
                .requestId(command.getRequestId())
                .accountId(command.getAccountId())
                .firstName(command.getFirstName())
                .lastName(command.getLastName())
                .email(command.getEmail())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void updateAccount(UpdateAccountCommand command) {
        AccountUpdatedEvent event = AccountUpdatedEvent.builder()
                .requestId(command.getRequestId())
                .accountId(command.getAccountId())
                .firstName(command.getFirstName())
                .lastName(command.getLastName())
                .email(command.getEmail())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void deleteAccount(DeleteAccountCommand command) {
        AccountDeletedEvent event = AccountDeletedEvent.builder()
                .requestId(command.getRequestId())
                .accountId(command.getAccountId())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @EventSourcingHandler
    public void on(AccountCreatedEvent event) {
        this.accountId = event.getAccountId();
        this.firstName = event.getFirstName();
        this.lastName = event.getLastName();
        this.email = event.getEmail();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(AccountUpdatedEvent event) {
        this.firstName = event.getFirstName();
        this.lastName = event.getLastName();
        this.email = event.getEmail();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(AccountDeletedEvent event) {
        AggregateLifecycle.markDeleted();

        LogHelper.logEventProcessed(LOGGER, event);
    }
}

And here is the current implementation of a BusinessAggregate:

@Aggregate
@NoArgsConstructor
@Getter
public class BusinessAggregate {

    @Autowired
    private CommandGateway commandGateway;

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

    @AggregateIdentifier
    private String businessId;
    private String businessName;

    @CommandHandler
    public BusinessAggregate(EnrollBusinessCommand command) {
        BusinessEnrolledEvent event = BusinessEnrolledEvent.builder()
                .requestId(command.getRequestId())
                .businessId(command.getBusinessId())
                .businessName(command.getBusinessName())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void updateBusiness(UpdateBusinessCommand command) {
        BusinessUpdatedEvent event = BusinessUpdatedEvent.builder()
                .requestId(command.getRequestId())
                .businessId(command.getBusinessId())
                .businessName(command.getBusinessName())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @CommandHandler
    public void deleteBusiness(DeleteBusinessCommand command) {
        BusinessDeletedEvent event = BusinessDeletedEvent.builder()
                .requestId(command.getRequestId())
                .businessId(command.getBusinessId())
                .build();

        LogHelper.logCommandIssuingEvent(LOGGER, command, event);

        AggregateLifecycle.apply(event);
    }

    @EventSourcingHandler
    public void on(BusinessEnrolledEvent event) {
        this.businessId = event.getBusinessId();
        this.businessName = event.getBusinessName();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(BusinessUpdatedEvent event) {
        this.businessId = event.getBusinessId();
        this.businessName = event.getBusinessName();

        LogHelper.logEventProcessed(LOGGER, event);
    }

    @EventSourcingHandler
    public void on(BusinessDeletedEvent event) {
        AggregateLifecycle.markDeleted();

        LogHelper.logEventProcessed(LOGGER, event);
    }
}

As you can see the LoyaltyBankAggregate is probably the most complex of the three and I don’t think I want to replicate that logic between the AccountAggregate and BusinessAggregate classes.

Following this train of thought, I have two ideas that have come up from this discussion.

1. The first would be to retain the LoyaltyBankAggregate class, and add sets of loyaltyBankIds to the AccountAggregate and BusinessAggregate classes. This will allow me to remove the interceptor and command projections for when a CreateLoyaltyBankCommand is issued.

To ensure that an account, business, and loyalty bank, all have the correct relationships, I would create a Saga that handles a LoyaltyBankCreatedEvent and issues an AddLoyaltyBankToAccountCommand that might look something like the following:

@Getter
@SuperBuilder
public class AddLoyaltyBankToAccountCommand extends AbstractCommand {

    @TargetAggregateIdentifier
    private String accountId;
    private String loyaltyBankId;

    @Override
    public void validate() {
        super.validate();
        throwExceptionIfParameterIsNullOrBlank(this.getAccountId(), ACCOUNT_ID_CANNOT_BE_EMPTY);
        throwExceptionIfParameterIsNullOrBlank(this.getLoyaltyBankId(), LOYALTY_BANK_ID_CANNOT_BE_EMPTY);
    }
}

This would then be handled by the AccountAggregate to issue a LoyaltyBankAddedToAccountEvent if the loyaltyBankId was not in the aggregate’s set. The event would be used in an event sourcing handler to add the loyaltyBankId to the set. This event would also be handled by the Saga to issue an AddLoyaltyBankToBusinessCommand which would essentially go through the same process as the account but for the business. I would also need to introduce flows for handling errors for if the account or business doesn’t exist, to then go back and clean up the created LoyaltyBankAggregate and remove it from the AccountAggregate set if possible.

To me it seems that this would introduce more overhead and complexity than the interceptor approach. While the response time from the CreateLoyaltyBankCommand should be faster since it is being handled immediately by the aggregate. There is still a chance that it will need to be removed later by the Saga on an error. I could probably change the order of operations in the Saga to simplify the cleanup, and that might still be faster than the interceptor because the validation will be going against the aggregate in memory as opposed to a database lookup, but I’d probably have to measure that to see what the cost of rehydrating the aggregate on the command would be by comparison.

Is my understanding here correct, or am I missing something that the framework offers to help mitigate these costs?

2. The other option I see is to to essentially convert the LoyaltyBankAggregate to a different class called LoyaltyBank and use that as an an aggregate member on both the AccountAggregate and BusinessAggregate. What I am wondering though is if you can have a class be an aggregate member on more than one aggregate? This would allow me to keep the logic for a LoyaltyBank consolidated to this class, but I don’t really understand how this would be possible with how aggregates are rehydrated when a command is issued, and what the target aggregate identifier on the command for the loyalty transactions should be.

Again I am looking to see if my thought process here is correct, or if there is a feature of the framework or some other concept that I am missing that would allow me to accomplish what was previously discussed?

All that being said, I know this is probably moving a bit too far down the “premature optimization” road. I really appreciate the conversation, and hope this helps others be better able to design their business domains in the future. Thank you again for your time and consideration.

That’s correct.

A saga would indeed be the means to consolidate between different command model types. Added, your assumption is correct that it becomes “iffy” as well when taking this route. Simply because you are generation more hops by using separate messages for all of this.

This is, in part, why I did not recommend a saga, but a duplication of the entity within the models it is necessary for. With duplication of an entity, I do not necessarily mean duplication of the entire block of code. Just the parts that are needed for the AccountAggregate and BusinessAggregate respectively.

Axon Framework wouldn’t care if you reuse the same class for this. So, in short, that is doable.

However, I don’t think it is helpful from a project management perspective.
Added, and more important, there will come a point that you will add logic to the LoyaltyBank class that is only required for the AccountAggregate or the BusinessAggregate.

You would thus construct a model that is split between different concerns. Essentially the point why CQRS is so helpful; splitting up models to be focused on their actual intent makes it so that they can be optimized for their use cases.


Now, whether the above pointers help you further, @ajisrael, is a bit tough for me to tell. Although discussing somebody else’s domain is doable online, I will never be the domain expert that can make a educated decision if X, or Y, or Z is the right course of action.

Nonetheless, I do hope the above does help somewhat, Alex!

Ow, and lastly, I spotted that your aggregates wire the CommandGateway as part of their global state. I would not recommend using the CommandGateway, or CommandBus, inside the aggregate/command model. When dispatching a command from within a command or event sourcing handler, you are essentially extending the scope of command handling. Potentially such that you are blocking other parts of your application.

So, as a rule of thumb, only dispatch events from an aggregate, and that’s it.

1 Like

Thank you so much for the discussion @Steven_van_Beelen. This has been very informative. I think I’m going to keep my current approach and closely monitor for any performance impact from the interceptors.

And thank you for the note about the CommandGateway. I’m not sure why that was in there as I’m not actually using it in the aggregate, but I will be sure to remove it promptly.

1 Like

I am glad to help you out, @ajisrael! Keeping to your current approach seems like a good way forward to me, too :slight_smile:
Hope to talk to you here or somewhere else in the future!

1 Like