Is it bad practice, Recreating aggregate model in another aggregate?

I have 2 aggregates UserAggregate and ProductAggregate. When a user create an order it requires 2 things productId and its price. As I know, in event-sourcing we should never rely on read model due to eventual-consistency, but I really need this 2 field in order to process. So I found this post on stackoverflow cqrs - How to read events stored in Axon Event Store? - Stack Overflow which gives an advice on how to recreating aggregate model. So I tried it and it looks like this.

@Aggregate
class UserAggregate {
  @AggregateIdentifier
  lateinit var userId: String
  
  var balance = 0

  fun handle(cmd: CreateOrderCommand, repository: EventSourcingRepository<ProductAggregate>){
    val productPrice = repository.load(cmd.productId)
                                  .wrappedAggregate
                                  .aggregateRoot
                                  .price
    if (productPrice > balance){
      throw IllegalArgumentException("Insufficient balance")
    }

    // do whatever
  }
}

@Aggregate
class ProductAggregate {
  @AggregateIdentifier
  lateinit var productId: String

  val price = 10
}

So it seems to work, but is it bad practice? since these two aggregates have different aggregate identifier it may have some consistency problem like this (I’m not sure how AxonServer deal with incoming commands).

I may overthinking btw or it’s just my bad aggregate design.

Hey there, thanks for posting your question. I have to write the famous non-answer here: it all depends. Luckily I can tell you a bit more than only that.

Consistency
Outside of the boundary of the aggregate, nothing Is consistent. Not even rebuilding another aggregate. Because you are building it with the events that are in the store at that moment, and as you illustrated the event that is changing the price can be applied after the current command.

What does it depend on?
Aggregate design and consistency is an area where trade-offs need to be made depending on the requirements of your business. For example, in your case I would ask:

  • How often do prices change?
  • How bad is it if once or twice a month a product is ordered for a price that was updated two seconds ago?

I will answer the rest here with some assumptions. I will assume that the prices are updated once a week or so, and that it’s not the biggest disaster if it’s ordered for a price up to 5 seconds ago (since the user will probably see the old price in the UI when ordering also).

How to get the price?
So, assuming that eventual consistency here is alright up to a certain extent,you can retrieve the price by querying a projection (through injecting resources). You can have a specific productId-price projection that is a subscribing event processor and up-to-date as much as possible.

This is not a bad practice, as you feared. Again, it all depends. If something is not clear, or you have more questions, drop another comment!

2 Likes

Thank you a lot for answering my question, I always wonder how people deal with consistency problem. Btw I have one more question. so yesterday, I was thinking all day long and I made another approach would this work (without any consistency problem) since AxonServer handles each command in sequence.

@Aggregate
class ProductAggregate {

    private int amountOfProduct;
    private int price;

    @CommandHandler
    public void handle(CreateOrderCommand cmd, CommandGateway commandGateway) {
        if (amountOfProduct == 0){
            throw new IllegalArgumentException("Out of stock");
        }
        commandGateway.sendAndWait(new DeductBalanceCommand(cmd.getUserId(), price));
        apply(new OrderCreatedEvent(cmd.getProductId(), cmd.getUserId()));
    }
}

@Aggregate
class UserAggregate {

    private int balance;
    
    @CommandHandler
    public void handle(DeductBalanceCommand cmd) {
        if (balance < cmd.getPrice()){
            throw new IllegalArgumentException("Insufficient balance");
        }
        apply(new BalanceDeductedEvent(cmd.getUserId(), cmd.getAmount()));
    }
}

I would suggest moving the DeductBalanceCommand into an event processing group with appropriate error handling, for a couple of reasons:

  • Sending a command from a CommandHandler is generally not recommended because of the performance impact.
  • What if the CreateOrderCommand fails (lost connection for example) after the DeductBalanceCommand already completed? The user would lose balance without having the order placed

What would be best in this case, is to have a processing group taking care of this. The flow could look like this:

  • OrderCreatedEvent
  • Success scenario: DeductBalanceCommand → BalanceDeductedCommand(orderId)
  • Failure scenario: CancelOrderCommand → OrderCancelledEvent
  • ApproveOrderPaidCommand → OrderApprovedEvent

This way, the communication between the aggregates is failure-proof and error scenarios are being handled properly.

1 Like