Update: The issue seems to be the id that I’m using twice, or in other words, the id from the product entity that I want to use for the productinventory entity. As soon as I generate a new id for the productinventory entity, it seems to work fine. But I want to have the same id for both, since they’re the same product.
I have 2 Services:
ProductManagementService (saves a Product entity with product details)
1.) For saving the Product Entity, I implemented an EventHandler that listens to ProductCreatedEvent and saves the product to a mysql database.
ProductInventoryService (saves a ProductInventory entity with stock quantities of product to a certain productId defined in ProductManagementService )
2.) For saving the ProductInventory Entity, I also implemented an EventHandler that listens to ProductInventoryCreatedEvent and saves the product to a mysql database.
What I want to do:
When a new Product is created in ProductManagementService, I want to create a ProductInventory entity in ProductInventoryService directly afterwards and save it to my msql table. The new ProductInventory entity shall have the same id as the Product entity.
For that to accomplish, I created a Saga, which listes to a ProductCreatedEvent and sends a new CreateProductInventoryCommand. As soon as the CreateProductInventoryCommand triggers a ProductInventoryCreatedEvent, the EventHandler as described in 2.) should catch it. Except it doesn’t.
The only thing thta gets saved is the Product Entity, so in summary:
1.) works, 2.) doesn’t. A ProductInventory Aggregate does get created, but it doesn’t get saved since the saving process that is connected to an EventHandler isn’t triggered.
I also get an Exception, the application doesn’t crash though: Command 'com.myApplication.apicore.command.CreateProductInventoryCommand' resulted in org.axonframework.commandhandling.CommandExecutionException(OUT_OF_RANGE: [AXONIQ-2000] Invalid sequence number 0 for aggregate 3cd71e21-3720-403b-9182-130d61760117, expected 1)
My Saga:
@Saga @ProcessingGroup("ProductCreationSaga") public class ProductCreationSaga { @Autowired private transient CommandGateway commandGateway; @StartSaga @SagaEventHandler(associationProperty = "productId") public void handle(ProductCreatedEvent event) { System.out.println("ProductCreationSaga, SagaEventHandler, ProductCreatedEvent"); String productInventoryId = event.productId; SagaLifecycle.associateWith("productInventoryId", productInventoryId); //takes ID from product entity and sets all 3 stock attributes to zero commandGateway.send(new CreateProductInventoryCommand(productInventoryId, 0, 0, 0)); } @SagaEventHandler(associationProperty = "productInventoryId") public void handle(ProductInventoryCreatedEvent event) { System.out.println("ProductCreationSaga, SagaEventHandler, ProductInventoryCreatedEvent"); SagaLifecycle.end(); } }
The EventHandler that works as intended and saves a Product Entity:
@Component public class ProductPersistenceService { @Autowired private ProductEntityRepository productRepository; //works as intended @EventHandler void on(ProductCreatedEvent event) { System.out.println("ProductPersistenceService, EventHandler, ProductCreatedEvent"); ProductEntity entity = new ProductEntity(event.productId, event.productName, event.productDescription, event.productPrice); productRepository.save(entity); } @EventHandler void on(ProductNameChangedEvent event) { System.out.println("ProductPersistenceService, EventHandler, ProductNameChangedEvent"); ProductEntity existingEntity = productRepository.findById(event.productId).get(); ProductEntity entity = new ProductEntity(event.productId, event.productName, existingEntity.getProductDescription(), existingEntity.getProductPrice()); productRepository.save(entity); } }
The EventHandler that should save a ProductInventory Entity, but doesn’t:
@Component public class ProductInventoryPersistenceService { @Autowired private ProductInventoryEntityRepository productInventoryRepository; //doesn't work @EventHandler void on(ProductInventoryCreatedEvent event) { System.out.println("ProductInventoryPersistenceService, EventHandler, ProductInventoryCreatedEvent"); ProductInventoryEntity entity = new ProductInventoryEntity(event.productInventoryId, event.physicalStock, event.reservedStock, event.availableStock); System.out.println(entity.toString()); productInventoryRepository.save(entity); } }
Product-Aggregate:
@Aggregate public class Product { @AggregateIdentifier private String productId; private String productName; private String productDescription; private double productPrice; public Product() { } @CommandHandler public Product(CreateProductCommand command) { System.out.println("Product, CommandHandler, CreateProductCommand"); AggregateLifecycle.apply(new ProductCreatedEvent(command.productId, command.productName, command.productDescription, command.productPrice)); } @EventSourcingHandler protected void on(ProductCreatedEvent event) { System.out.println("Product, EventSourcingHandler, ProductCreatedEvent"); this.productId = event.productId; this.productName = event.productName; this.productDescription = event.productDescription; this.productPrice = event.productPrice; } }
ProductInventory-Aggregate:
@Aggregate public class ProductInventory { @AggregateIdentifier private String productInventoryId; private int physicalStock; private int reservedStock; private int availableStock; public ProductInventory() { } @CommandHandler public ProductInventory(CreateProductInventoryCommand command) { System.out.println("ProductInventory, CommandHandler, CreateProductInventoryCommand"); AggregateLifecycle.apply(new ProductInventoryCreatedEvent(command.productInventoryId, command.physicalStock, command.reservedStock, command.availableStock)); } @EventSourcingHandler protected void on(ProductInventoryCreatedEvent event) { System.out.println("ProductInventory, EventSourcingHandler, ProductInventoryCreatedEvent"); this.productInventoryId = event.productInventoryId; this.physicalStock = event.physicalStock; this.reservedStock = event.reservedStock; this.availableStock = event.availableStock; } }
Advertisement
Answer
What you are noticing right now is the uniqueness requirement of the [aggregate identifier, sequence number] pair within a given Event Store. This requirement is in place to safe guard you from potential concurrent access on the same aggregate instance, as several events for the same aggregate all need to have a unique overall sequence number. This number is furthermore use to identify the order in which events need to be handled to guarantee the Aggregate is recreated in the same order consistently.
So, you might think this would opt for a “sorry there is no solution in place”, but that is luckily not the case. There are roughly three things you can do in this set up:
- Life with the fact both aggregates will have unique identifiers.
- Use distinct bounded contexts between both applications.
- Change the way aggregate identifiers are written.
Option 1 is arguably the most pragmatic and used by the majority. You have however noted the reuse of the identifier is necessary, so I am assuming you have already disregarded this as an option entirely. Regardless, I would try to revisit this approach as using UUID
s per default for each new entity you create can safe you from trouble in the future.
Option 2 would reflect itself with the Bounded Context notion pulled in by DDD. Letting the Product
aggregate and ProductInventory
aggregate reside in distinct contexts will mean you will have distinct event stores for both. Thus, the uniqueness constraint would be kept, as no single store is containing both aggregate event streams. Whether this approach is feasible however depends on whether both aggregates actually belong to the same context yes/no. If this is the case, you could for example use Axon Server’s multi-context support to create two distinct applications.
Option 3 requires a little bit of insight in what Axon does. When it stores an event, it will invoke the toString()
method on the @AggregateIdentifier
annotated field within the Aggregate. As your @AggregateIdentifier
annotated field is a String
, you are given the identifier as is. What you could do is have typed identifiers, for which the toString()
method doesn’t return only the identifier, but it appends the aggregate type to it. Doing so will make the stored aggregateIdentifier
unique, whereas from the usage perspective it still seems like you are reusing the identifier.
Which of the three options suits your solution better is hard to deduce from my perspective. What I did do, is order them in most reasonable from my perspective. Hoping this will help your further @Jan!