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.