Basic information
This behavior came up in a discussion. Actually I´m not 100% sure what the desired behavior is and if this is a bug or not. Feel free to close with a description, so we can reference this in the future.
- Axon Framework version:
- JDK version:
- Complete executable reproducer if available (e.g. GitHub Repo):
Steps to reproduce
The test case here calls SpeedoMeterRemoved - EventSourcing Handler is called on both entities despite the fact
that the sourcing criteria uses different tag values.
package io.axoniq.demo.bikerental.speedometer;
import org.axonframework.eventsourcing.annotation.EventCriteriaBuilder;
import org.axonframework.eventsourcing.annotation.EventSourcedEntity;
import org.axonframework.eventsourcing.annotation.EventSourcingHandler;
import org.axonframework.eventsourcing.annotation.EventTag;
import org.axonframework.eventsourcing.annotation.reflection.EntityCreator;
import org.axonframework.eventsourcing.configuration.EventSourcedEntityModule;
import org.axonframework.eventsourcing.configuration.EventSourcingConfigurer;
import org.axonframework.messaging.commandhandling.annotation.CommandHandler;
import org.axonframework.messaging.commandhandling.configuration.CommandHandlingModule;
import org.axonframework.messaging.core.unitofwork.ProcessingContext;
import org.axonframework.messaging.eventhandling.annotation.Event;
import org.axonframework.messaging.eventhandling.gateway.EventAppender;
import org.axonframework.messaging.eventstreaming.EventCriteria;
import org.axonframework.modelling.StateManager;
import org.axonframework.modelling.annotation.InjectEntity;
import org.axonframework.modelling.annotation.TargetEntityId;
import org.axonframework.modelling.repository.Repository;
import org.axonframework.test.fixture.AxonTestFixture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
/**
* Test case to reproduce the issue where events are applied to both entities
* when a CommandHandler works with 2 Entities after migration from Axon Framework 4 to 5.
*
*
* Expected behavior: Events should only be applied to the entity they target
* - SpeedoMeterRemoved event should only be applied to bike1
* - SpeedoMeterMounted event should only be applied to bike2
*
* Actual behavior (bug): Each event is applied to both entities
* - Both bike1 and bike2 receive SpeedoMeterRemoved event
* - Both bike1 and bike2 receive SpeedoMeterMounted event
* - This causes an IllegalStateException when bike2 tries to handle the removal event
*/
public class SpeedometerMigrationTest {
private AxonTestFixture fixture;
@BeforeEach
public void setUp() {
EventSourcingConfigurer eventSourcingConfigurer = EventSourcingConfigurer.create()
.registerEntity(EventSourcedEntityModule.autodetected(String.class, RentableBike.class))
.modelling(
modellingConfigurer -> modellingConfigurer.registerCommandHandlingModule(
() -> CommandHandlingModule.named("command-handler")
.commandHandlers()
.autodetectedCommandHandlingComponent(
config -> new DomainService(
config.getComponent(StateManager.class)
.repository(RentableBike.class, String.class)))
.build()));
fixture = AxonTestFixture.with(eventSourcingConfigurer);
}
/**
* This test demonstrates the issue where:
* 1. We have two bikes: bike1 (with speedometer) and bike2 (without speedometer)
* 2. We execute MoveSpeedometerCommand to move speedometer from bike1 to bike2
* 3. The DomainService loads both bikes and appends events:
* - SpeedoMeterRemoved for bike1
* - SpeedoMeterMounted for bike2
* 4. BUG: Both events are incorrectly applied to both bike entities
* 5. This causes bike2 to throw IllegalStateException when trying to handle the removal event
*/
@Test
public void moveSpeedometer() {
fixture.given()
.events(List.of(
new Events.RentableBikeWasAdded("bike1"),
new Events.RentableBikeWasAdded("bike2"),
new Events.SpeedoMeterMounted("bike1", new SpeedometerId("speedo1"))))
.when()
.command(new Commands.MoveSpeedometerCommand("bike1", "bike2"))
.then()
.events(
new Events.SpeedoMeterRemoved("bike1", new SpeedometerId("speedo1")),
new Events.SpeedoMeterMounted("bike2", new SpeedometerId("speedo1")));
}
// ========== Supporting Classes ==========
/**
* Commands for the test
*/
public static class Commands {
public record AddNewBikeCommand(@TargetEntityId String bikeId) {
}
public record AddSpeedometerCommand(@TargetEntityId String bikeId, SpeedometerId speedometerId) {
}
public record MoveSpeedometerCommand(String fromBikeId, String toBikeId) {
}
}
/**
* Events for the test
*/
public static class Events {
@Event
public record RentableBikeWasAdded(@EventTag(key = "RentableBike") String bikeId) {
}
@Event
public record SpeedoMeterMounted(@EventTag(key = "RentableBike") String bikeId, SpeedometerId speedometerId) {
}
@Event
public record SpeedoMeterRemoved(@EventTag(key = "RentableBike") String bikeId, SpeedometerId speedometerId) {
}
}
/**
* SpeedometerId value object
*/
public record SpeedometerId(String id) {
}
/**
* RentableBike entity with speedometer handling
*/
@EventSourcedEntity
public static class RentableBike {
private static final Logger LOG = LoggerFactory.getLogger(RentableBike.class);
private String bikeId;
private SpeedometerId mountedSpeedometerId;
/**
* Private constructor for the Axon framework
*/
@EntityCreator
RentableBike() {
}
public RentableBike(String bikeId) {
this.bikeId = bikeId;
}
@CommandHandler
public static RentableBike on(Commands.AddNewBikeCommand command, EventAppender eventAppender) {
eventAppender.append(new Events.RentableBikeWasAdded(command.bikeId()));
return new RentableBike(command.bikeId());
}
@CommandHandler
public void mountSpeedometer(Commands.AddSpeedometerCommand command, EventAppender eventAppender) {
mountSpeedometer(command.speedometerId(), eventAppender);
}
public void mountSpeedometer(SpeedometerId speedometerId, EventAppender eventAppender) {
eventAppender.append(new Events.SpeedoMeterMounted(bikeId, speedometerId));
}
public Optional<SpeedometerId> removeSpeedoMeter(EventAppender eventAppender) {
SpeedometerId speedometerId = this.mountedSpeedometerId;
eventAppender.append(new Events.SpeedoMeterRemoved(bikeId, speedometerId));
return Optional.ofNullable(speedometerId);
}
@EventSourcingHandler
void on(Events.RentableBikeWasAdded event) {
this.bikeId = event.bikeId();
LOG.info("RentableBikeWasAdded: {}", event.bikeId());
}
@EventSourcingHandler
void on(Events.SpeedoMeterMounted event) {
this.mountedSpeedometerId = event.speedometerId();
LOG.info("SpeedoMeter {} mounted on bike {}. Event contains bike {}",
event.speedometerId(), bikeId, event.bikeId());
}
@EventSourcingHandler
void on(Events.SpeedoMeterRemoved event) {
if (this.mountedSpeedometerId == null) {
throw new IllegalStateException("No speedometer mounted on bike " + bikeId +
" to remove. Event contains bike " + event.bikeId());
}
this.mountedSpeedometerId = null;
LOG.info("SpeedoMeter {} removed from bike {}. Event contains bike {}",
event.speedometerId(), bikeId, event.bikeId());
}
@EventCriteriaBuilder
private static EventCriteria resolve(String id) {
return EventCriteria.havingTags("RentableBike", id);
}
}
/**
* Domain service that handles moving speedometer between bikes
*/
public static class DomainService {
private final Repository<String, RentableBike> bikeRepository;
public DomainService(Repository<String, RentableBike> bikeRepository) {
this.bikeRepository = bikeRepository;
}
@CommandHandler
void handle(Commands.MoveSpeedometerCommand command,
@InjectEntity(idProperty = "fromBikeId") RentableBike bike1,
@InjectEntity(idProperty = "toBikeId") RentableBike bike2,
ProcessingContext processingContext, EventAppender eventAppender) {
SpeedometerId speedometerId = bike1.removeSpeedoMeter(eventAppender)
.orElseThrow(() -> new IllegalStateException("No speedometer mounted on bike " + command.fromBikeId()));
bike2.mountSpeedometer(speedometerId, eventAppender);
}
}
}
Expected behaviour
The expected behavior was that SpeedoMeterRemoved is only called on RentableBike bike 1, not both.
Actual behaviour
SpeedoMeterRemoved Event Sourcing Handler is called on both entities.
From the documentation it is not clear, how to handle this behavior properly.
If this is desired, we should add a section in the documetation maybe ( or point to the one if it exists ) and keep this issue as reference.
Basic information
This behavior came up in a discussion. Actually I´m not 100% sure what the desired behavior is and if this is a bug or not. Feel free to close with a description, so we can reference this in the future.
Steps to reproduce
The test case here calls SpeedoMeterRemoved - EventSourcing Handler is called on both entities despite the fact
that the sourcing criteria uses different tag values.
Expected behaviour
The expected behavior was that SpeedoMeterRemoved is only called on RentableBike bike 1, not both.
Actual behaviour
SpeedoMeterRemoved Event Sourcing Handler is called on both entities.
From the documentation it is not clear, how to handle this behavior properly.
If this is desired, we should add a section in the documetation maybe ( or point to the one if it exists ) and keep this issue as reference.