'Axon Aggregate Identifier Type Converter

PROBLEM: In an aggregate-state Axon SpringBoot application @AggregateIdentifier is of type UUID and PostgreSQL database column is of type UUID. When persisted on create-commands - identifier is successfully stored. When sending update-command warning rises and command is not delivered to aggregate handler, because @TargetAggregateIdentifier expects String type as described by Axon here:

java.lang.IllegalArgumentException: Provided id of the wrong type for class ....MyAggregate. Expected: class java.util.UUID, got class java.lang.String

RESEARCH:

  1. Working solution is to refactor the domain to String types for all aggregate indentifiers. Database @Id filed also should be converted to varchar(36) type to store UUID.toString() as primary key. Major minus is that it's inefficient: 9 times bigger size and slower String reads.

  2. Minimal boilerplate and compromise solution is to refactor the domain to String types for all aggregate indentifiers and use javax.persistence.Converters to convert String to UUID in JPA layer while persisting:

    @Converter
    public class UuidJpaConverter implements AttributeConverter<String, UUID> {
    
      @Override
      public UUID convertToDatabaseColumn(String uuid) {
        return fromString(uuid);
      }
    
      @Override
      public String convertToEntityAttribute(UUID uuid) {
        return uuid.toString();
      }
    }
    
    ...
    
    @Aggregate
    @Entity
    @IdClass(UuidKey.class)
    public class MyAggregate implements Serializable {
    
      @AggregateIdentifier
      @Id
      private String uuid;
      ...
    }
    
    public class UuidKey implements Serializable {
    
      @Column(name = "uuid", nullable = false, updatable = false)
      @Convert(converter = UuidJpaConverter.class)
      private String uuid;
    }
    

But it results in:

o.h.engine.jdbc.spi.SqlExceptionHelper : ERROR: column "uuid" is of type uuid but expression is of type bytea

  1. Use Axon identifier converter, but it requires custom GenericJpaRepository for each aggregate and actually never calls identifierConverter handler on a breakpoint:

     @Configuration
     public class IdentifierConverter {
    
       @Bean
       public GenericJpaRepository<MyAggregate> aggregateJpaRepository(
           EntityManagerProvider provider,
           @Qualifier("eventBus") EventBus simpleEventBus) {
    
         GenericJpaRepository<MyAggregate> repository = GenericJpaRepository
             .builder(MyAggregate.class)
      ->       .identifierConverter(name -> UUID.fromString(name))
             .entityManagerProvider(provider)
             .eventBus(simpleEventBus)
             .build();
    
         return repository;
       }
     }
    

And results in:

o.h.engine.jdbc.spi.SqlExceptionHelper : ERROR: column "uuid" is of type uuid but expression is of type character varying

  1. There is also a suggested universal GenericJpaRepository solution to find the type of the aggregate identifier and - unless it is already a String - convert it via the Spring conversion service. But it is not clear where to bind it added it to the registerAggregateBeanDefinitions in the configurer when we have Axon autoconfiguration and beans are expected in favour of Configurer:

     final Class<?> aggregateIdentifierType = Stream.of( aggregateType.getDeclaredFields( ) )
     .filter( field -> field.isAnnotationPresent( AggregateIdentifier.class ) )
     .map( field -> field.getType( ) )
     .findFirst( )
     .orElseThrow( ( ) -> new IllegalStateException( "The aggregate '" + aggregate + "' does not have an identifier." ) );
    
     aggregateConf.configureRepository(
     c -> GenericJpaRepository.builder( aggregateType )
             .identifierConverter( string -> {
                 if ( aggregateType == String.class ) {
                     return string;
                 } else {
                     try {
                         final ConversionService conversionService = beanFactory.getBean( ConversionService.class );
                         return conversionService.convert( string, aggregateIdentifierType );
                     } catch ( final NoSuchBeanDefinitionException ex ) {
                         throw new IllegalStateException( "Unable to convert String to aggregate identifier of type '" + aggregateIdentifierType.getName( ) + "'. A conversion service is missing." );
                     }
                 }
             } )
    
     ...
    
     @Named
     final class MyIdConverter implements Converter<String, MyId> {
    
         ...
    
         @Override
         public MyId convert( final String source ) {
             return MyId.fromString( source );
         }
    
     }
    

QUESTION: How to keep UUID type of aggregate identifier in PostgreSQL database - with UUID as preferred type for @AggregateIdentifier or at least String. Also why is only String currently supported if at 2010 UUID was widely used in Axon?



Solution 1:[1]

Honestly, you've gone pretty deep on the matter here @Zon. Unsure whether I can help you sufficiently, but I will give it a go regardless.

Research point 1 is obviously the most pragmatic solution to get things to work right now. If you are going to notice the "ineffeciency" of String compared to UUID is something I'd be hard pressed about. So if this is an absolute no no, investigation should proceed. Otherwise, it does get the job done of course.

When it comes to research points 2 and 3, I believe you are hitting an issue with the dialect being used for PostgreSQL, although I am not a 100% certain here. Especially PostgreSQL brandishes a couple of "awesome" types, but these do not always automatically work in all scenarios. I am basing my "guesswork" here on forcing PostgreSQL to use BYTEA instead of OID in case you want to should down Postgres' TOAST capability. This becomes especially handy if you opt to use Postgres for your event store and want to be able to actually see the contents of events. This blog post for example specifies how to deal with this. More importantly, this blog post shows how you could for example adjust the dialect being used. Maybe that could serve you in solutions 2 and 3?

Option 4 should in this case be the most logical solution to take. But I gather from your response that you didn't get it to work at the moment. When combing Axon with Spring, the SpringAxonAutoConfigurer (from which you are referring the registerAggregateBeanDefinitions method from I believe) will automatically check for configurable beans on your Aggregate. It does so based on fields defined in the @Aggregate (i.e. Axon's Spring stereotype annotation). More specifically, you can use the repository field in the @Aggregate to define the bean name of the repository you want to use.

You should thus simply be able to provide a GenericJpaRepository bean with the desired identifierConverter. The name of that bean can than be specified in the @Aggregate annotation on your MyAggregate, so that Axon's auto config can pick it up correctly. Hope this helps you out!

Solution 2:[2]

Here is a working solution for option #3, creating a custom GenericJpaRepository for each aggregate. Similar how Spring does a component scan, this will scan for classes with @Aggregate from a base package, which can either be defined in your properties file or replace in this config class with a literal.

@Configuration
public class GenericJpaRepositoryConfig {

    @Value("${base-package}")
    private String basePackage;

    @Autowired
    public void registerGenericJpaRepositories(ApplicationContext applicationContext,
                                               EntityManagerProvider provider,
                                               @Qualifier("eventBus") EventBus simpleEventBus) throws ClassNotFoundException {

        List<Class<?>> classes = this.getAggregateClasses();

        ConfigurableApplicationContext context = (ConfigurableApplicationContext) applicationContext;
        DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory();

        for (Class<?> clazz : classes) {
            GenericJpaRepository<?> repository = aggregateJpaRepository(provider, simpleEventBus, clazz);
            beanFactory.registerSingleton("axon" + clazz.getSimpleName() + "Repository", repository);
        }

    }

    private <T> GenericJpaRepository<T> aggregateJpaRepository(
            EntityManagerProvider provider,
            EventBus simpleEventBus,
            Class<T> className) {

        return GenericJpaRepository
                .builder(className)
                .identifierConverter(UUID::fromString)
                .entityManagerProvider(provider)
                .eventBus(simpleEventBus)
                .build();
    }

    private List<Class<?>> getAggregateClasses() throws ClassNotFoundException {
        ClassPathScanningCandidateComponentProvider scanner =
                new ClassPathScanningCandidateComponentProvider(true);

        List<Class<?>> classes = new ArrayList<>();

        if (basePackage == null || basePackage.isEmpty()) {
            basePackage = this.getClass().getPackageName();
        }

        for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) {
            Class<?> clazz = Class.forName(bd.getBeanClassName());
            Aggregate annotation = clazz.getAnnotation(Aggregate.class);

            if (annotation != null) {
                classes.add(clazz);
            }

        }

        return classes;
    }
}

Note that each created GenericJpaRepository has a name of the format axon<AggregateName>Repository. To get this to work with your aggregate class that you named MyAggregate you will need the annotation with the repository value: @Aggregate(repository = "axonMyAggregateRepository"). Adding the repository name to the aggregate annotation was mentioned in Steven's answer, and is probably why your solution didn't work.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Steven
Solution 2 Michael Jeszenka