@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.