Skip to content

Sourcing Entities should respect SourcingCriteria #4766

Description

@dilgeraxoniq

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.

Metadata

Metadata

Labels

No labels
No labels

Type

Fields

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions