When developers set up and integrate services, they often face challenges that can take up a lot of time. Starters help simplify this process by organizing code and making it easier to manage. Let’s take a look at creating two starters, configuring their settings automatically, and using them in a service.

So, what are Spring Boot Starters, exactly? What benefits do they provide?

Spring Boot Starters are like packages that streamline the process of incorporating libraries and components into Spring projects, making it simpler and more efficient to manage dependencies while cutting down development time significantly.

Benefits of Using Spring Boot Starter

Integration of Libraries

  • Starters include all the dependencies needed for technologies. For example spring-boot-starter-web provides everything for building web applications, while spring-boot-starter-data-jpa helps with JPA database work.
  • By adding these starters to a project, developers can start working with the desired technology without worrying about compatibility issues or version differences.

Focus on Business Logic

  • Developers can concentrate on creating business logic for dealing with infrastructure code.
  • This approach speeds up development and feature deployment, ultimately boosting team productivity.

Using Configurations

  • Using predefined setups helps ensure consistency in setting up and organizing projects, making it easier to maintain and advance code. Moreover, it aids in onboarding team members to the project by offering a code structure and setup.

Project Enhancements

  • Furthermore, using starters that include known libraries simplifies updating dependencies and integrating Spring Boot versions.
  • The support from the Spring team community linked with these starters also guarantees to resolve any questions or obstacles that might come up during development.

Task Description

In this article, we will address the issue of consolidating data from sources such as REST and GraphQL services. This problem is often encountered in projects with microservice architecture, where it is necessary to combine data coming from different services.

When it comes to solutions in a microservices setup, it’s possible to establish microservices for each integration. This approach is justifiable when the integration is extensive, and there are resources for its maintenance. However, in scenarios like working with a monolith or lacking the resources for multiple microservices support, opting for starters could be more practical.

The rationale behind selecting a library starter includes:

  • Business logic segmentation. Starters facilitate the separation of business logic and integration configuration.
  • Following the SOLID principles. Breaking down functionality into modules aligns with principles enhancing code maintainability and scalability.
  • Simplified setup. Starters streamline the process of configuring services by minimizing the required amount of configuration code.
  • Ease of use. Integrating a service becomes more straightforward by adding a dependency and configuring essential parameters.

Our Scenario

Let’s illustrate the solution with an example involving a tour aggregator that gathers data from tour operators and merges them. To start off, we will develop two starters (tour-operator-one-starter and tour-operator-two-starter) both of which will use a shared module (common-model) containing fundamental models and interfaces. These starter libraries will connect to the aggregator service (tour-aggregator).

Creating tour-operator-one-starter

Starter is designed to integrate with the tour operator and fetch data via the REST API.

All official starters use the naming scheme spring-boot-starter-*, where * denotes a specific type of application. Third-party starters should not start with spring-boot as it is reserved for official starters from the Spring team.

Typically, third-party starters begin with the project name. For example, my starter will be named tour-operator-one-spring-boot-starter.

1. Create pom.xml

Add dependencies.

org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-configuration-processor true com.common.model common-model 0.0.1-SNAPSHOT org.springframework.boot spring-boot-starter-test test    

2. Create TourOperatorOneProperties

These are the properties we will set in tour-aggregator to configure our starter.

@ConfigurationProperties(prefix = "tour-operator.one.service")
public class TourOperatorOneProperties {

    private final Boolean enabled;
    private final String url;
    private final Credentials credentials;

    public TourOperatorOneProperties(
            Boolean enabled,
            String url,
            Credentials credentials) {
        this.enabled = enabled;
        this.url = url;
        this.credentials = credentials;
    }

    //getters

    public static class Credentials {
        private final String username;
        private final String password;

        public Credentials(String username, String password) {
            this.username = username;
            this.password = password;
        }

        //getters
    }
}

3. Create TourOperatorOneAutoConfiguration

  • @AutoConfiguration – indicates that this class is a configuration class for Spring Boot auto-configuration.
  • @ConditionalOnProperty – activates the configuration if the property tour-operator.one.service.enabled is set to true. If the property is missing, the configuration is also activated due to matchIfMissing = true.
  • @EnableConfigurationProperties(TourOperatorOneProperties.class) – enables support for @ConfigurationProperties annotations for the TourOperatorOneProperties class.
{
if (null != properties.getCredentials()) {
httpHeaders.setBasicAuth(
properties.getCredentials().getUsername(),
properties.getCredentials().getPassword());
}
})
.build();
}

@Bean(“tourOperatorOneService”)
public TourOperatorOneServiceImpl tourOperatorService(TourOperatorOneProperties properties,
@Qualifier(“operatorOneRestClient”) RestClient restClient) {
log.info(“Configuration tourOperatorService: {} and restClient: {}”, properties, restClient);
return new TourOperatorOneServiceImpl(restClient);
}
}” data-lang=”application/xml”>

@AutoConfiguration
@ConditionalOnProperty(prefix = "tour-operator.one.service", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(TourOperatorOneProperties.class)
public class TourOperatorOneAutoconfiguration {

    private static final Logger log = LoggerFactory.getLogger(TourOperatorOneAutoconfiguration.class);
    private final TourOperatorOneProperties properties;

    public TourOperatorOneAutoconfiguration(TourOperatorOneProperties properties) {
        this.properties = properties;
    }

    @Bean("operatorOneRestClient")
    public RestClient restClient(RestClient.Builder builder) {
        log.info("Configuration operatorRestClient: {}", properties);
        return builder
                .baseUrl(properties.getUrl())
                .defaultHeaders(httpHeaders -> {
                    if (null != properties.getCredentials()) {
                        httpHeaders.setBasicAuth(
                                properties.getCredentials().getUsername(),
                                properties.getCredentials().getPassword());
                    }
                })
                .build();
    }

    @Bean("tourOperatorOneService")
    public TourOperatorOneServiceImpl tourOperatorService(TourOperatorOneProperties properties,
                                                          @Qualifier("operatorOneRestClient") RestClient restClient) {
        log.info("Configuration tourOperatorService: {} and restClient: {}", properties, restClient);
        return new TourOperatorOneServiceImpl(restClient);
    }
}

In this example, I use @ConditionalOnProperty, but there are many other conditional annotations:

  1. @ConditionalOnBean – generates a bean when a specified bean exists in the BeanFactory
  2. @ConditionalOnMissingBean – facilitates creating a bean if a particular bean is not found in the BeanFactory
  3. @ConditionalOnClass – produces a bean when a specific class is present, in the classpath
  4. @ConditionalOnMissingClass – acts oppositely to @ConditionalOnClass

You should choose what suits your needs best. You can learn more about conditional annotations here.

4. Create TourOperatorOneServiceImpl

In this class, we implement the base interface and lay down the main business logic for retrieving data from the first tour operator and standardizing it according to the common interface.

>() {
});

return TourOperatorResponse.builder()
.deals(responseList
.getBody()
.stream()
.map(ModelUtils::mapToCommonModel)
.toList())
.build();
}
}” data-lang=”text/plain”>

public class TourOperatorOneServiceImpl implements TourOperatorService {

    private final RestClient restClient;

    public TourOperatorOneServiceImpl(@Qualifier("operatorOneRestClient") RestClient restClient) {
        this.restClient = restClient;
    }

    @Override
    public TourOperatorResponse makeRequest(TourOperatorRequest request) {
        var tourRequest = mapToOperatorRequest(request); // transformation of our request into the one that the tour operator will understand

        var responseList = restClient
                .post()
                .body(tourRequest)
                .retrieve()
                .toEntity(new ParameterizedTypeReference>() {
                });

        return TourOperatorResponse.builder()
                .deals(responseList
                        .getBody()
                        .stream()
                        .map(ModelUtils::mapToCommonModel)
                        .toList())
                .build();
    }
}

5. Create Auto-Configuration File

To register auto-configurations, we create the file resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports .

com.tour.operator.one.autoconfiguration.TourOperatorOneAutoConfiguration

This file contains a collection of configurations. In my scenario, one configuration is listed. If you have multiple configurations, make sure that each configuration is listed on a separate line.

By creating this file, you are informing Spring Boot that it should load and utilize the TourOperatorOneAutoConfiguration class for setup when certain conditions specified by the @ConditionalOnProperty annotation are satisfied.

Thus, we have established the setup for collaborating with the tour operator by developing configuration classes and beans and leveraging properties.

Creating tour-operator-two-starter 

Up is creating tour-operator-two-starter a kit designed to integrate with the second tour operator and retrieve data from a GraphQL server through a straightforward HTTP request.

Let’s proceed with the process used for tour-operator-one-starter.

1. Create pom.xml

Add dependencies.

org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-configuration-processor true com.common.model common-model 0.0.1-SNAPSHOT org.springframework.boot spring-boot-starter-test test    

2. Create TourOperatorTwoProperties

These are the properties we will set in tour-aggregator to configure our starter.

@ConfigurationProperties(prefix = "tour-operator.two.service")
public class TourOperatorTwoProperties {

    private final Boolean enabled;
    private final String url;
    private final String apiKey;

    public TourOperatorTwoProperties(
            Boolean enabled,
            String url,
            String apiKey) {
        this.enabled = enabled;
        this.url = url;
        this.apiKey = apiKey;
    }

    //getters
}

2. Create TourOperatorOneAutoConfiguration

{
httpHeaders.set(“X-Api-Key”, properties.getApiKey());
})
.build();
}

@Bean(“tourOperatorTwoService”)
public TourOperatorTwoServiceImpl tourOperatorService(TourOperatorTwoProperties properties,
@Qualifier(“operatorTwoRestClient”) RestClient restClient) {
log.info(“Configuration tourOperatorService: {} and restClient: {}”, properties, restClient);
return new TourOperatorTwoServiceImpl(restClient);
}
}” data-lang=”text/x-java”>

@AutoConfiguration
@ConditionalOnProperty(prefix = "tour-operator.two.service", name = "enabled", havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(TourOperatorTwoProperties.class)
public class TourOperatorTwoAutoconfiguration {

    private static final Logger log = LoggerFactory.getLogger(TourOperatorTwoAutoconfiguration.class);
    private final TourOperatorTwoProperties properties;

    public TourOperatorTwoAutoconfiguration(TourOperatorTwoProperties properties) {
        log.info("Configuration with: {}", properties);
        this.properties = properties;
    }

    @Bean("operatorTwoRestClient")
    public RestClient restClient(RestClient.Builder builder) {
        log.info("Configuration operatorRestClient: {}", properties);
        return builder
                .baseUrl(properties.getUrl())
                .defaultHeaders(httpHeaders -> {
                    httpHeaders.set("X-Api-Key", properties.getApiKey());
                })
                .build();
    }

    @Bean("tourOperatorTwoService")
    public TourOperatorTwoServiceImpl tourOperatorService(TourOperatorTwoProperties properties,
                                                          @Qualifier("operatorTwoRestClient") RestClient restClient) {
        log.info("Configuration tourOperatorService: {} and restClient: {}", properties, restClient);
        return new TourOperatorTwoServiceImpl(restClient);
    }
}

3. Create TourOperatorOneServiceImpl

Receiving data from the second tour operator.

public class TourOperatorTwoServiceImpl implements TourOperatorService {

    private static final String QUERY =
            """
                    query makeTourRequest($request: TourOperatorRequest) {
                                      makeTourRequest(request: $request) {
                                        id
                                        startDate
                                        endDate
                                        price
                                        currency
                                        days
                                        hotel {
                                          hotelName
                                          hotelRating
                                          countryCode
                                        }
                                      }
                                    }
                    """;

    private final RestClient restClient;

    public TourOperatorTwoServiceImpl(@Qualifier("operatorTwoRestClient") RestClient restClient) {
        this.restClient = restClient;
    }

    @Override
    public TourOperatorResponse makeRequest(TourOperatorRequest request) {
        var tourRequest = mapToOperatorRequest(request);
        var variables = Map.ofEntries(Map.entry("request", tourRequest));
        var requestBody = Map.ofEntries(
                Map.entry("query", QUERY),
                Map.entry("variables", variables));

        var response = restClient
                .post()
                .body(requestBody)
                .retrieve()
                .toEntity(QueryResponse.class);

        return TourOperatorResponse.builder()
                .deals(response.getBody()
                        .data()
                        .makeTourRequest()
                        .stream()
                        .map(ModelUtils::mapToCommonModel).toList())
                .build();
    }
}

Create Auto-Configuration File

Create the file resources/META-INF/spring/org.springframework.boot. autoconfigure.AutoConfiguration.imports.

com.tour.operator.two.autoconfiguration.TourOperatorTwoAutoconfiguration

Creating and Using the Aggregator Service

An aggregator service is designed to gather data from tour operators. This involves linking starters, configuring parameters, and using beans with a shared interface.

1. Connect Starter Libraries

Include dependencies for the two libraries in the pom.xml.

... com.tour.operator tour-operator-one-spring-boot-starter 0.0.2-SNAPSHOT com.tour.operator tour-operator-two-spring-boot-starter 0.0.1-SNAPSHOT ...    

Configure Parameters in application.yaml

Specify the necessary data, such as URLs and connection parameters, in the application.yaml.

spring:
  application:
    name: tour-aggregator
tour-operator:
  one:
    service:
      enabled: true
      url: http://localhost: 8090/api/tours
      credentials:
        username: user123
        password: pass123
  two:
    service:
      enabled: true
      url: http://localhost: 8091/graphql
      api-key: 11d1de45-5743-4b58-9e08-f6038fe05c8f

Use Services

We use the established beans, which implement the TourOperatorService interface within the TourServiceImpl class. This class outlines the process of retrieving and aggregating data from various tour operators.

@Service public class TourServiceImpl implements TourService { private static final Logger log = LoggerFactory.getLogger(TourServiceImpl.class); private final List tourOperatorServices; private final Executor tourOperatorExecutor; private final Integer responseTimeout; public TourServiceImpl(List tourOperatorServices, @Qualifier("tourOperatorTaskExecutor") Executor tourOperatorExecutor, @Value("${app.response-timeout:5}") Integer responseTimeout) { this.tourOperatorServices = tourOperatorServices; this.tourOperatorExecutor = tourOperatorExecutor; this.responseTimeout = responseTimeout; } public List getTourOffers(@RequestBody TourOperatorRequest request) { log.info("Send request: {}", request); var futures = tourOperatorServices.stream() .map(tourOperator -> CompletableFuture.supplyAsync(() -> tourOperator.makeRequest(request), tourOperatorExecutor) .orTimeout(responseTimeout, TimeUnit.SECONDS) .exceptionally(ex -> TourOperatorResponse.builder().deals(List.of()).build()) ) .toList(); var response = futures.stream() .map(CompletableFuture::join) .map(TourOperatorResponse::getDeals) .filter(Objects::nonNull) .flatMap(List::stream) .toList(); return response; } }

Allocating Resources for Calls

It’s good practice to allocate separate resources for calls, allowing better thread management and performance optimization.

@Configuration
public class ThreadPoolConfig {

    private final Integer threadCount;

    public ThreadPoolConfig(@Value("${app.thread-count:5}") Integer threadCount) {
        this.threadCount = threadCount;
    }

    @Bean(name = "tourOperatorTaskExecutor")
    public Executor tourOperatorTaskExecutor() {
        return Executors.newFixedThreadPool(threadCount);
    }
}

This code ensures efficient management of asynchronous tasks and helps avoid blocking the main thread, thereby improving overall system performance.

Conclusion

In this article, we’ve created two starters for reaching out to tour operators through REST and GraphQL technology interfaces. These steps include all the configurations and elements to simplify their usage. Afterward, we merged them into a system that communicates with them in an asynchronous manner and aggregates data.

This approach solved several problems:

  • Simplified integration and setup. By using auto-configuration and properties of coding, we saved time during development.
  • Improved flexibility and usability. Separating functions into starters improved code structure and simplified maintenance.
  • System flexibility. We can easily add new integrations without breaking the existing logic.

Now, our system is better equipped to adapt and scale effortlessly while being easier to manage, leading to enhancements in its architecture and performance.

Here’s the full code.

I appreciate you reading this article. I look forward to hearing your thoughts and feedback!

Opinions expressed by DZone contributors are their own.