0

Small question on Spring Boot, and how to use a design pattern combined with Spring @Value configuration in order to select the appropriate @Repository please.

Setup: A springboot project which does nothing but save a pojo. The "difficulty" is the need to choose where to save the pojo, based on some info from inside the payload request.

I started with a first straightforward version, which looks like this:

   @RestController
public class ControllerVersionOne {

    @Autowired private ElasticRepository elasticRepository;
    @Autowired private MongoDbRepository mongoRepository;
    @Autowired private RedisRepository redisRepository;

    //imagine many more other repositories
//imagine many more other repositories
//imagine many more other repositories

    @PostMapping(path = "/save")
    public String save(@RequestBody MyRequest myRequest) {
        String whereToSave = myRequest.getWhereToSave();
        MyPojo myPojo = new MyPojo(UUID.randomUUID().toString(), myRequest.getValue());
        if (whereToSave.equals("elastic")) {
            return elasticRepository.save(myPojo).toString();
        } else if (whereToSave.equals("mongo")) {
            return mongoRepository.save(myPojo).toString();
        } else if (whereToSave.equals("redis")) {
            return redisRepository.save(myPojo).toString();
            // imagine many more if 
            // imagine many more if 
            // imagine many more if 

        } else {
            return "unknown destination";
        }
    }

With the appropriate @Configuration and @Repository for each and every databases. I am showing 3 here, but imagine many. The project has a way to inject future @Configuration and @Repository as well (the question is not here actually)

@Configuration
public class ElasticConfiguration extends ElasticsearchConfiguration {

@Repository
public interface ElasticRepository extends CrudRepository<MyPojo, String> {


@Configuration
public class MongoConfiguration extends AbstractMongoClientConfiguration {

@Repository
public interface MongoDbRepository extends MongoRepository<MyPojo, String> {


@Configuration
public class RedisConfiguration {

@Repository
public interface RedisRepository {

Please note, some of the repositories are not children of CrudRepository. There is no direct ___Repository which can cover everything.

And this first version is working fine. Very happy, meaning I am able to save the pojo to where it should be saved, as I am getting the correct repository bean, using this if else structure. In my opinion, this structure is not very elegant (if it ok if we have different opinion here), especially, not flexible at all (need to hardcode each and every possible repository, again imagine many).

This is why I went to refactor and change to this second version:

@RestController
public class ControllerVersionTwo {

    private ElasticRepository elasticRepository;
    private MongoDbRepository mongoRepository;
    private RedisRepository redisRepository;
    private Map<String, Function<MyPojo, MyPojo>> designPattern;

    @Autowired
    public ControllerVersionTwo(ElasticRepository elasticRepository, MongoDbRepository mongoRepository, RedisRepository redisRepository) {
        this.elasticRepository = elasticRepository;
        this.mongoRepository = mongoRepository;
        this.redisRepository = redisRepository;
// many more repositories
        designPattern = new HashMap<>();
        designPattern.put("elastic", myPojo -> elasticRepository.save(myPojo));
        designPattern.put("mongo", myPojo -> mongoRepository.save(myPojo));
        designPattern.put("redis", myPojo -> redisRepository.save(myPojo));
//many more put
    }

    @PostMapping(path = "/save")
    public String save(@RequestBody MyRequest myRequest) {
        String whereToSave = myRequest.getWhereToSave();
        MyPojo myPojo = new MyPojo(UUID.randomUUID().toString(), myRequest.getValue());
        return designPattern.get(whereToSave).apply(myPojo).toString();
    }

As you can see, I am leveraging a design pattern refactoring the if-else into a hashmap.

This post is not about if-else vs hashmap by the way.

Working fine, but please note, the map is a Map<String, Function<MyPojo, MyPojo>>, as I cannot construct a map of Map<String, @Repository>.

With this second version, the if-else is being refactored, but again, we need to hardcode the hashmap.

This is why I am having the idea to build a third version, where I can configure the map itself, via a spring boot property @Value for Map:

Here is what I tried:

@RestController
public class ControllerVersionThree {

    @Value("#{${configuration.design.pattern.map}}")
    Map<String, String> configurationDesignPatternMap;

    private Map<String, Function<MyPojo, MyPojo>> designPatternStrategy;

    public ControllerVersionThree() {
        convertConfigurationDesignPatternMapToDesignPatternStrategy(configurationDesignPatternMap, designPatternStrategy);
    }

    private void convertConfigurationDesignPatternMapToDesignPatternStrategy(Map<String, String> configurationDesignPatternMap, Map<String, Function<MyPojo, MyPojo>> designPatternStrategy) {
        // convert configurationDesignPatternMap
        // {elastic:ElasticRepository, mongo:MongoDbRepository , redis:RedisRepository , ...}
        // to a map where I can directly get the appropriate repository based on the key
    }

    @PostMapping(path = "/save")
    public String save(@RequestBody MyRequest myRequest) {
        String whereToSave = myRequest.getWhereToSave();
        MyPojo myPojo = new MyPojo(UUID.randomUUID().toString(), myRequest.getValue());
        return designPatternStrategy.get(whereToSave).apply(myPojo).toString();
    } 

And I would configure in the property file:

configuration.design.pattern.map={elastic:ElasticRepository, mongo:MongoDbRepository , saveToRedis:RedisRepositry, redis:RedisRepository , ...}

And tomorrow, I would be able to configure add or remove the future repository target.

configuration.design.pattern.map={elastic:ElasticRepository, anotherElasticKeyForSameElasticRepository, redis:RedisRepository , postgre:PostGreRepository}

Unfortunately, I am stuck.

What is the correct code in order to leverage a configurable property for mapping a key with it's "which @Repository to use" please?

Thank you for your help.

PatPanda
  • 3,644
  • 9
  • 58
  • 154

3 Answers3

3

You can create a base repository to be extended by all your repositories:

public interface BaseRepository {
    MyPojo save(MyPojo onboarding);
}

so you will have a bunch of repositories like:

@Repository("repoA")
public interface ARepository extends JpaRepository<MyPojo, String>, BaseRepository {
}

@Repository("repoB")
public interface BRepository extends JpaRepository<MyPojo, String>, BaseRepository {
}

...

Those repositories will be provided by a factory:

public interface BaseRepositoryFactory {
    BaseRepository getBaseRepository(String whereToSave);
}

that you must configure in a ServiceLocatorFactoryBean:

@Bean
public ServiceLocatorFactoryBean baseRepositoryBean() {
    ServiceLocatorFactoryBean serviceLocatorFactoryBean = new ServiceLocatorFactoryBean();
    serviceLocatorFactoryBean.setServiceLocatorInterface(BaseRepositoryFactory.class);
    return serviceLocatorFactoryBean;
}

Now you can inject the factory wherever you need and get the repo want:

@Autowired
private BaseRepositoryFactory baseRepositoryFactory;

...

baseRepositoryFactory.getBaseRepository("repoA").save(myPojo);

...

Hope it helps.

eltabo
  • 3,749
  • 1
  • 21
  • 33
  • Hello @eltabo, thank you for this answer. This solution is very nice, very clean. Leveraging a Factory is quite smart. However, since repositories will extends two interfaces with save(), there is a risk of ```reference to save is ambiguous``` as well as ```types my.repository.BaseRepository and org.springframework.data.repository.reactive.ReactiveCrudRepository are incompatible;``` – PatPanda Dec 16 '22 at 02:29
  • Furthermore, I am quite interested in the ```@Value``` Map, because it offers the ability to configure multiple "whereToSave" mapping to one same repository. Instead of having ```@Repository("repoA")```, I can do in the map {repoA:ARepository, mongo:MongoDbRepository , yetAnotherWhereToWithValueA:ARepositry} – PatPanda Dec 16 '22 at 02:33
  • With that said, this solution is very advanced and nice, upvote – PatPanda Dec 16 '22 at 02:33
  • In that case I think your propossed solution have the same incompatibility with ReactiveCrudRepository because you rely on a Function, anyway let's take a look – eltabo Dec 16 '22 at 23:11
1

Have you tried creating a configuration class to create your repository map

@Configuration
public class MyConfiguration {

  @Bean
  public Map repositoryMap() {
    Map<String, ? extends Repository> repositoryMap = new HashMap<>();
    
    repositoryMap.put('redis', new RedisRepository());
    repositoryMap.put('mongo', new MongoRepository());
    repositoryMap.put('elastic', new ElasticRepository());
    
    return Collections.unmodifiableMap(repositoryMap);
  
  }


}

Then you could have the following in your rest controller

@RestController
@Configuration
public class ControllerVersionFour {

    @Autowired
    private Map<String, ? extends Repository> repositoryMap;
    
    @PostMapping(path = "/save/{dbname}")
    public String save(@RequestBody MyRequest myRequest,  @PathVariable("dbname") String dbname) {
        MyPojo myPojo = new MyPojo(UUID.randomUUID().toString(), myRequest.getValue());
        return repisitoryMap.get(dbname).save(myPojo);
    }

It might be better to have the db as a path/query parameter instead of having it in the request body. That way you may or may not be able to just save the request body depending on your use case instead of creating another pojo.

This post may also be useful for autowiring a map

mh377
  • 1,656
  • 5
  • 22
  • 41
  • Hello @mh377, this is a great post! Thanks! However, having the ```@Bean public Map repositoryMap() {```, even in a separate @Configuration, is still requiring the code level to have the hash map hard coded. This is a much better version of my ControllerVersionTwo, but still requires the code to hold the mapping. What I am looking for, is for a configuration from property file to hold the configuration – PatPanda Dec 17 '22 at 10:16
  • Then if you want to have it in configuration e.g. store the class names in a list a yaml the you probably want to create a new instance based on those class names using reflection https://stackoverflow.com/questions/6094575/creating-an-instance-using-the-class-name-and-calling-constructor – mh377 Dec 17 '22 at 10:54
  • If you define a name for each `@Repository` component, then `@Autowired` a `Map`, maybe you don't even have to hardcode the map. [Documentation says](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/factory/annotation/Autowired.html) "In case of an array, Collection, or Map dependency type, the container autowires all beans matching the declared value type.". Maybe that is what you are looking for. – Raphallal Dec 21 '22 at 16:25
1

Short answer:

  • create a shared interface
  • create multiple sub-class of this interface (one per storage) using different spring component names
  • Use a map to deal with aliases
  • use Spring context to retrieve the right bean by alias (instead of creating a custom factory)

Now adding a new storage is only adding a new Repository classes with a name

Explanation: As mentioned in the other answer you first need to define a common interface as you can't use the CrudRepository.save(...). In my example I reuse the same signature as the save method to avoid re-implementing it in the sub-classes of CrudRepository.

public interface MyInterface<T> {
    <S extends T> S save(S entity);
}

Redis Repository:

@Repository("redis") // Here is the name of the redis repo
public class RedisRepository implements MyInterface<MyPojo>  {
    @Override
    public <S extends MyPojo> S save(S entity) {
        entity.setValue(entity.getValue() + " saved by redis");
        return entity;
    }
}

For the other CrudRepository no need to provide an implementation:

@Repository("elastic") // Here is the name of the elastic repo
public interface ElasticRepository  extends CrudRepository<MyPojo, String>, MyInterface<MyPojo> {
}

Create a configuration for your aliases in application.yml

configuration:
  design:
    pattern:
      map:
        redis: redis
        saveToRedisPlease: redis
        elastic: elastic

Create a custom properties to retrieve the map:

@Component
@ConfigurationProperties(prefix = "configuration.design.pattern")
public class PatternProperties {
    private Map<String, String>  map;

    public String getRepoName(String alias) {
        return map.get(alias);
    }

    public Map<String, String> getMap() {
        return map;
    }

    public void setMap(Map<String, String> map) {
        this.map = map;
    }
}

Now create the version three of your repository with the injection of SpringContext:

@RestController
public class ControllerVersionThree {

    private final ApplicationContext context;

    private PatternProperties designPatternMap;

    public ControllerVersionThree(ApplicationContext context,
                                  PatternProperties designPatternMap) {
        this.context = context;
        this.designPatternMap = designPatternMap;
    }

    @PostMapping(path = "/save")
    public String save(@RequestBody MyRequest myRequest) {
        String whereToSave = myRequest.getWhereToSave();
        MyPojo myPojo = new MyPojo(UUID.randomUUID().toString(), myRequest.getValue());
        String repoName = designPatternMap.getRepoName(whereToSave);
        MyInterface<MyPojo> repo = context.getBean(repoName, MyInterface.class);
        return repo.save(myPojo).toString();
    }

}

You can check that this is working with a test:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ControllerVersionThreeTest {
    @LocalServerPort
    private int port;
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void testSaveByRedis() {
        // Given: here 'redis' is the name of the spring beans
        HttpEntity<MyRequest> request = new HttpEntity<>(new MyRequest("redis", "aValue"));

        // When
        String response = restTemplate.postForObject("http://localhost:" + port + "/save", request, String.class);

        // Then
        assertEquals("MyPojo{value='aValue saved by redis'}", response);
    }

    @Test
    void testSaveByRedisAlias() {
        // Given: here 'saveToRedisPlease' is an alias name of the spring beans
        HttpEntity<MyRequest> request = new HttpEntity<>(new MyRequest("saveToRedisPlease", "aValue"));

        // When
        String response = restTemplate.postForObject("http://localhost:" + port + "/save", request, String.class);

        // Then
        assertEquals("MyPojo{value='aValue saved by redis'}", response);
    }

}
Philippe K
  • 143
  • 5
  • Hello @Philippe, this is a very nice answer. This common interface + ```applicationContext.getBean()``` is working like a charm. However, I am still looking for a map configured by @Value. Because in your example, the "whereToSave" can only be "redis". I am hoping for a map style configuration where I can map multiple keys to RedisRepository, for instance, a map like this will have two keys mapping to redis repo: ```{redis:RedisRepository, mongo:MongoDbRepository , saveToRedisPlease:RedisRepositry}```. With that said, your solution is very nice. Upvote – PatPanda Dec 22 '22 at 12:42
  • I change the implementation to retrieve a map that deal with aliases – Philippe K Dec 22 '22 at 15:23
  • This is correct, thank you @Philippe! Thanks for the clear explanations – PatPanda Dec 23 '22 at 00:42