Conditional initialization of spring beans using annotation

Published On: 2021/09/30

How to disable or enable a bean based on the deployment environment was the question we had back in 2015 when implementing a retail banking project. Now also most of the spring developers would like to know how to conditionally enable the spring beans. I would scribble down the way how we had achieved this in the banking project by setting up a sample project.

Create sample project

Let us setup a simple banking application using the latest spring boot version. When writing this article, I have used the version 2.5.5 of spring boot starter. You could use the spring starter generator to prepare a starter project.

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.5.5</version>
		<relativePath/>
	</parent>

Configure the project

As a security measure, it would be good to remove the arguments parameter from SpringApplication.run if you are not intented to pass any command line arguments to the application.

@SpringBootApplication
public class CloudBankApplication {

	public static void main(String[] args) {
		SpringApplication.run(CloudBankApplication.class);
	}

}

Now let us create a simple rest controller and try to access resource path. I have separated the definition and implementation of this controller using an interface and concrete class. The getAccount method returns the account data object.

@RestController
@RequestMapping("/accounts")
public interface AccountController {

  @RequestMapping(value = "/{accountId}", method = RequestMethod.GET, headers = "Accept=application/json")
  ResponseEntity< AccountDto > getAccount(@PathVariable("accountId") String accountId);

}

public class AccountControllerBean implements AccountController{
  
  @Override
  public ResponseEntity< AccountDto > getAccount(String accountId) {
    return ResponseEntity.ok(new AccountDto(accountId,"aNumber","aCustomerNumber"));
  }
}

When you start the application and try to access the http://localhost:8080 the page will ask you to enter username and password. This is because the basic authentication is auto configured during the application startup. As we dont need any authentication mechanism at this point let us disable it by adding a security configuration.

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity security) throws Exception
  {
    // to disable login screen when starting spring boot application
    security.httpBasic().disable();
  }
}

Use Conditional annotation to enable bean

Now we could access the resource with the url http://localhost:8080/accounts/1234. Let us move to experiment the conditional enabling of beans using the Conditional annotation provided by spring framework. To explain this in the banking application context, I would use a user story.

@Story: The banking application should have mock enabled in the local environment and the mocks should be disabled in all other environments. The push notification should be enabled only in the non development environment. The swagger interface should be available only in non production(like)environments. The application has to be deployed in multiple environments like local,dev,ci,sit,uat,loadtest,preprod,prod etc. There is a chance that , multiple UAT and Loadtest environments will be introduced to test different versions of the application.

To achieve this scenario, we have created a startup context class to set the configuration parameters like swaggerEnabled,pushNotificationEnabled,mockEnabled.

  private final Boolean swaggerEnabled;
  private final Boolean pushNotificationEnabled;
  private final Boolean mockEnabled;
  private String someOtherConfig;

  public ApplicationStartupContext(Boolean swaggerEnabled, Boolean pushNotificationEnabled, Boolean mockEnabled) {
    this.swaggerEnabled = swaggerEnabled;
    this.pushNotificationEnabled = pushNotificationEnabled;
    this.mockEnabled = mockEnabled;
  }

We wanted to apply an AND operator on the values given in the Profiles annotation so that the production configuration parameters for the non development/sit envoirments can be easly enabled.

  @Bean("bankApplicationContext")
  @Profile({"!local","!dev", "!ci", "!sit"})
  @Conditional(value = {AndProfilesCondition.class})
  public ApplicationStartupContext prodApplicationStartupContext(){
    ApplicationStartupContext startupContext = new ApplicationStartupContext(false,true,false);
    startupContext.setSomeOtherConfig("some thing");
    return startupContext;
  }

  @Bean("bankApplicationContext")
  @Profile({"dev","ci", "sit"})
  public ApplicationStartupContext nonProdApplicationStartupContext(){
    return new ApplicationStartupContext(true,true,false);
  }

  @Bean("bankApplicationContext")
  @Profile({"local"})
  public ApplicationStartupContext localApplicationStartupContext(){
    return new ApplicationStartupContext(true,false,true);
  }

The class AndProfilesCondition implement the logic to handle the negated profiles in the Profile annotation.

public class AndProfilesCondition implements Condition {
  public static final String VALUE = "value";
  public static final String DEFAULT_VALUE="development";

  @Override
  public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {

    MultiValueMap<String, Object> attrs = metadata.getAllAnnotationAttributes(Profile.class.getName());
    if(attrs == null) {
      return true;
    }

    Set<String> activeProfilesCollection = Arrays.stream(context.getEnvironment().getActiveProfiles()).collect(
        Collectors.toSet());
    String[] definedProfiles = (String[]) attrs.getFirst(VALUE);
    Set<String> allowedProfiles = new HashSet<>(1);
    Set<String> restrictedProfiles = new HashSet<>(1);
    if(activeProfilesCollection.isEmpty()) {
      activeProfilesCollection.add(DEFAULT_VALUE);
    }

    if(definedProfiles != null){
      for(String nextDefinedProfile: definedProfiles) {
        if(!nextDefinedProfile.isEmpty() && nextDefinedProfile.charAt(0) == '!') {
          restrictedProfiles.add(nextDefinedProfile.substring(1));
          continue;
        }
        allowedProfiles.add(nextDefinedProfile);
      }
    }

    boolean allowed = isAllowed(activeProfilesCollection, allowedProfiles);
    boolean notRestricted = isNotRestricted(activeProfilesCollection, restrictedProfiles);

    return allowed && notRestricted;
  }

  private boolean isNotRestricted(Set< String> activeProfilesCollection, Set< String> restrictedProfiles) {
    boolean notRestricted = true;
    for(String restrictedProfile: restrictedProfiles) {
      notRestricted = notRestricted && !activeProfilesCollection.contains(restrictedProfile);
    }
    return notRestricted;
  }

  private boolean isAllowed(Set< String> activeProfilesCollection, Set< String> allowedProfiles) {
    boolean allowed = true;
    for(String allowedProfile: allowedProfiles) {
      allowed = allowed && activeProfilesCollection.contains(allowedProfile);
    }
    return allowed;
  }
  
}

The behavior of the bean initialization could be tested when you set the active profiles of the application in the environment variable spring_profiles_active. An exmple ins spring_profiles_active=local.

The method cashTransfer of the class AccountController uses the pushNotificationEnabled property of the ApplicationStartupContext to check whether the environment is ready for sending the notification to customer whenever there is a transaction initiated from customer account.

@RestController
@RequestMapping("/accounts")
public interface AccountController {
 ...

  @RequestMapping(value = "/cash-transfers", method = RequestMethod.POST, headers = "Accept=application/json")
  ResponseEntity<String> cashTransfer(@RequestBody CashTransferDto cashTransferDto);
}

// -----------------

public class AccountControllerBean implements AccountController{
  ...

  public AccountControllerBean(ApplicationStartupContext bankApplicationContext, AccountService accountService) {
    this.bankApplicationContext = bankApplicationContext;
    this.accountService = accountService;
  }

  ...

    @Override
  public ResponseEntity<String> cashTransfer(CashTransferDto cashTransferDto) {
    CashTransfer cashTransfer = mapToDomainObject(cashTransferDto);
    Account senderAccount = this.accountService.findAccountByNumber(cashTransferDto.getSenderAccount());
    String transferRef = "testTransfer";
    if(bankApplicationContext.isPushNotificationEnabled() && senderAccount.transactionAlert()) {
      this.accountService.sendTransactionAlert(senderAccount, cashTransfer);
    }
    return ResponseEntity.ok(transferRef);
  }
}

As this is a sample project, disable the csrf property otherwise you will get the 403 Forbidden error when you invoke a url with HTTP POST method. Add the configuration given below to the Security config. Please note that this is not a code used in production system.

  @Override
  protected void configure(HttpSecurity security) throws Exception
  {
    // to disable login screen when starting spring boot application
    security.httpBasic().disable();
    // to resolve 403 error in spring boot post request
    security.csrf().disable();
  }

Conclusion

In this article we have explored the way to use the conditional annotation of spring framework to initialize a bean based on an environment variable. The other conditional annotations that are available in the latest version of the framework will be covered in an another article.

comments powered by Disqus