02 Spring Bean Dependency Injection Common Errors Part 1

02 Spring Bean Dependency Injection Common Errors Part 1 #

Hello, I’m Fu Jian. In this lesson, we will talk about Spring’s @Autowired annotation.

When mentioning the advantages or features of Spring, we immediately think of the phrase “Inversion of Control, Dependency Injection”. And @Autowired is one of the core tools used to support dependency injection. On the surface, it is just an annotation and should not pose any problems in usage. However, in practice, we still encounter various errors, all of which can be considered classics. So in this lesson, I will take you through these classic mistakes and their underlying causes to prevent them from happening.

Case 1: Too Many Gifts, No Suitable Candidates #

When using @Autowired, whether you are a novice or an expert user of Spring, you may have encountered or caused a similar error:

required a single bean, but 2 were found

As the name suggests, we only need one bean, but in reality, we provide two (the “2” here can be any number greater than 1 in the actual error).

To reproduce this error, let’s write a sample case. Suppose we are developing a student enrollment management system and need to provide an API to remove a student based on their student ID. Obviously, maintaining student information requires a database to support it, so we can implement it roughly as follows:

@RestController
@Slf4j
@Validated
public class StudentController {
    @Autowired
    DataService dataService;

    @RequestMapping(path = "students/{id}", method = RequestMethod.DELETE)
    public void deleteStudent(@PathVariable("id") @Range(min = 1,max = 100) int id){
        dataService.deleteStudent(id);
    };
}

Here, DataService is an interface, and its implementation depends on Oracle. The code can be represented as follows:

public interface DataService {
    void deleteStudent(int id);
}

@Repository
@Slf4j
public class OracleDataService implements DataService{
    @Override
    public void deleteStudent(int id) {
        log.info("delete student info maintained by oracle");
    }
}

So far, running and testing the program is without any issues. But requirements are often continuous, and one day we may receive a cost-saving request to migrate some non-core business from Oracle to the community edition Cassandra. Naturally, we would add a new implementation of DataService first, with the following code:

@Repository
@Slf4j
public class CassandraDataService implements DataService{
    @Override
    public void deleteStudent(int id) {
        log.info("delete student info maintained by cassandra");
    }
}

In reality, when we complete the preparation to support multiple databases, the program will no longer start and will give the following error:

Error Screenshot

Obviously, the above error message is the one we are discussing in this section. So how is this error caused? Let’s analyze it specifically.

Case Analysis #

To find the root cause of this problem, we need to have some understanding of the principle of dependency injection implemented by @Autowired. First, let’s understand the occurrence position and core process of @Autowired.

When a bean is created, the core process consists of two basic steps:

  1. Execution of AbstractAutowireCapableBeanFactory#createBeanInstance method: this method uses constructor reflection to create the bean, which is equivalent to creating an instance of StudentController in this case.
  2. Execution of AbstractAutowireCapableBeanFactory#populate method: this method fills (i.e., sets) the bean. In this case, it is equivalent to setting the dataService property member annotated with @Autowired in the StudentController instance.

In step 2, the “populating” process is strongly related to the execution of various BeanPostProcessor handlers. The key code is as follows:

protected void populateBean(String beanName, RootBeanDefinition mbd, @Nullable BeanWrapper bw) {
      // omitted non-key code
      for (BeanPostProcessor bp : getBeanPostProcessors()) {
         if (bp instanceof InstantiationAwareBeanPostProcessor) {
            InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp;
            PropertyValues pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName);
          // omitted non-key code
         }
      }
   }   
}

During the execution of the above code, because StudentController has a member property dataService marked with @Autowired, the AutowiredAnnotationBeanPostProcessor (one type of BeanPostProcessor) is used to complete the “assembly” process: finding the suitable bean for DataService and setting it to StudentController#dataService. If we go deeper into this assembly process, it can be divided into two steps:

  1. Find all fields and methods that need dependency injection, referring to the code line in AutowiredAnnotationBeanPostProcessor#postProcessProperties:
InjectionMetadata metadata = findAutowiringMetadata(beanName, bean.getClass(), pvs);
  1. Use the found fields and methods to inject the actual bean, referring to the code line in AutowiredAnnotationBeanPostProcessor#inject:
inject(field, beanInstance, beanName, null);

Here, when executing findAutowiringMetadata, multiple DataService beans are found, which leads to the error message we encountered when running the program, “required a single bean, but 2 were found”.

  1. Find the dependency based on the dependency information and complete the injection. Taking field injection as an example, refer to the AutowiredFieldElement#inject method:
@Override
protected void inject(Object bean, @Nullable String beanName, @Nullable PropertyValues pvs) throws Throwable {
   Field field = (Field) this.member;
   Object value;
   // omit non-essential code
      try {
          DependencyDescriptor desc = new DependencyDescriptor(field, this.required);
         // Find the "dependency", desc is the DependencyDescriptor for "dataService"
         value = beanFactory.resolveDependency(desc, beanName, autowiredBeanNames, typeConverter);
      }
      
   }
   // omit non-essential code
   if (value != null) {
      ReflectionUtils.makeAccessible(field);
      // Assemble the "dependency"
      field.set(bean, value);
   }
}

With the above code, we have a basic understanding of where and how the @Autowired process occurs. It is obvious that the error in our case occurred during the process of “finding the dependency” mentioned above (line 9 of the above code). So what exactly happened? Let’s dig deeper.

To clearly show where the error occurred, we can use the perspective of debugging to show its location (i.e., the code snippet in DefaultListableBeanFactory#doResolveDependency), as shown in the following image:

As shown in the image, when we try to find the dependency based on the type DataService, we find two dependencies, CassandraDataService and OracleDataService. In this case, if both of the following conditions are met, then the error in this case will be thrown:

  1. The determineAutowireCandidate method is called to select the highest priority dependency, but no priority can be determined. The specific selection process can be referred to in DefaultListableBeanFactory#determineAutowireCandidate:
protected String determineAutowireCandidate(Map<String, Object> candidates, DependencyDescriptor descriptor) {
   Class<?> requiredType = descriptor.getDependencyType();
   String primaryCandidate = determinePrimaryCandidate(candidates, requiredType);
   if (primaryCandidate != null) {
      return primaryCandidate;
   }
   String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType);
   if (priorityCandidate != null) {
      return priorityCandidate;
   }
   // Fallback
   for (Map.Entry<String, Object> entry : candidates.entrySet()) {
      String candidateName = entry.getKey();
      Object beanInstance = entry.getValue();
      if ((beanInstance != null && this.resolvableDependencies.containsValue(beanInstance)) ||
            matchesBeanName(candidateName, descriptor.getDependencyName())) {
         return candidateName;
      }
   }
   return null;
}

As the code shows, the priority is determined based on the @Primary annotation first, then the @Priority annotation, and finally based on an exact match of the bean name. If these annotations that help determine the priority are not used and the name does not match exactly, null is returned to indicate that it cannot determine which one is the most suitable.

  1. The @Autowired annotation requires that the injection is required (i.e., required remains the default value of true), or the attribute type of the annotation is not a type that can accept multiple beans, such as an array, a map, or a collection. This can be seen from the implementation of DefaultListableBeanFactory#indicatesMultipleBeans:
private boolean indicatesMultipleBeans(Class<?> type) {
   return (type.isArray() || (type.isInterface() &&
         (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type))));
}

Comparing these two conditions with our case, it is obvious that the program in our case satisfies these conditions, so the error is not surprising. To better understand this design, we can think of it as when we encounter multiple choices that cannot be compared, but we have to make a choice, it is better to report an error directly, which can at least avoid more serious problems.

Issue Fix #

Based on the analysis of the source code, we can quickly find a solution to fix the problem: break either of the two conditions mentioned above, i.e., make the candidates have priorities or simply not be selected at all. However, please note that not every breaking of the conditions meets the actual requirements. For example, we can use the @Primary annotation to allow the annotated candidate to have a higher priority and avoid the error, but it may not meet the business requirements. It is like we need to be able to use both databases, rather than favoring one over the other.

@Repository
@Primary
@Slf4j
public class OracleDataService implements DataService{
    // omit non-essential code
}

Now, please carefully read the above two conditions. To support multiple DataService implementations and be able to accurately match the DataService to be selected in different business scenarios, we can modify the code as follows:

@Autowired
DataService oracleDataService;

As the code shows, the essence of the modification lies in matching the property name with the bean name to enable accurate injection: specify the property name as oracleDataService when Oracle is required, and specify the property name as cassandraDataService when Cassandra is required.

Case 2: Ignore Case Sensitivity when Explicitly Referencing Beans #

In addition to fixing the issue in Case 1, there is another commonly used solution, which is to use @Qualifier to explicitly specify which type of service to reference. For example:

@Autowired
@Qualifier("cassandraDataService")
DataService dataService;

The reason this approach solves the problem is that it allows finding a single bean (i.e., exact match), so there won’t be a subsequent decision-making process. This can be seen in the DefaultListableBeanFactory#doResolveDependency method:

@Nullable
public Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
      @Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {
      // Omit non-critical code
      // Bean resolution process
      Map<String, Object> matchingBeans = findAutowireCandidates(beanName, type, descriptor);
      if (matchingBeans.isEmpty()) {
         if (isRequired(descriptor)) {
            raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);
         }
         return null;
      }
      // Omit non-critical code
      if (matchingBeans.size() > 1) {
         // Omit decision-making process for multiple beans, which was the main focus of Case 1
      } 
     // Omit non-critical code
}

We use the name specified by @Qualifier to match, and ultimately find only one.

However, when using @Qualifier, we may sometimes make another classic mistake, which is ignoring the case sensitivity of the bean name. Let’s slightly transform the corrected case as follows:

@Autowired
@Qualifier("CassandraDataService")
DataService dataService;

Running the program, we will get the following error:

Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name ‘studentController’: Unsatisfied dependency expressed through field ‘dataService’; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type ‘com.spring.puzzle.class2.example2.DataService’ available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true), @org.springframework.beans.factory.annotation.Qualifier(value=CassandraDataService)}

Here, we can easily conclude that for the name of the bean, if it is not explicitly specified, it should be the class name, but the first letter should be lowercase. But does this conclusion hold?

Let’s test it again. Suppose we need to support a database like SQLite, and we define an implementation named SQLiteDataService. Then, based on the previous experience, we can easily use the following code to reference this implementation:

@Autowired
@Qualifier("sQLiteDataService")
DataService dataService;

Running the program with great confidence, we still encounter the same error, but if we change it to SQLiteDataService, then it runs successfully. This contradicts the previous conclusion. So, when explicitly referencing a bean, should the first letter be uppercase or lowercase?

Case Analysis #

For this kind of error message positioning, in fact, we have already posted it at the beginning of this case (i.e., line 9 of the second code snippet):

raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);

That is, when a bean cannot be found due to a name issue (such as incorrectly using the first letter of the bean), a NoSuchBeanDefinitionException will be thrown directly.

Here, what we really need to pay attention to is: for beans without explicitly set names, should the default first letter be uppercase or lowercase?

Based on the case, when we start a Spring Boot-based application, it automatically scans our packages to find the definitions (i.e., BeanDefinition) of beans that are directly or indirectly marked with @Component. For example, CassandraDataService and SQLiteDataService are both marked with @Repository, and Repository itself is marked with @Component, so they are indirectly marked with @Component.

Once these bean information is found, the names of these beans can be generated and combined into BeanDefinitionHolders, which are then returned to the upper layer. The critical steps in this process can be seen in the following code snippet from the ClassPathBeanDefinitionScanner#doScan method:

Basic matching is described in the previous process, where the method call BeanNameGenerator#generateBeanName is used to generate the name of the bean. There are two ways to implement it. Because the implementation of DataService is annotated, the logic for generating the bean name ultimately calls AnnotationBeanNameGenerator#generateBeanName. Let’s take a look at its specific implementation, the code is as follows:

@Override
public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) {
   if (definition instanceof AnnotatedBeanDefinition) {
      String beanName = determineBeanNameFromAnnotation((AnnotatedBeanDefinition) definition);
      if (StringUtils.hasText(beanName)) {
         // Explicit bean name found.
         return beanName;
      }
   }
   // Fallback: generate a unique default bean name.
   return buildDefaultBeanName(definition, registry);
}

The process consists of two steps: checking if the bean has an explicitly specified name. If so, use the explicit name. If not, generate a default name. Obviously, in our case, the bean name is not specified, so the generated bean name is the default name. Let’s take a look at the buildDefaultBeanName method, which generates the default name:

protected String buildDefaultBeanName(BeanDefinition definition) {
   String beanClassName = definition.getBeanClassName();
   Assert.state(beanClassName != null, "No bean class name set");
   String shortClassName = ClassUtils.getShortName(beanClassName);
   return Introspector.decapitalize(shortClassName);
}

First, it obtains a short class name, and then calls the Introspector#decapitalize method to set the first letter in lowercase or uppercase. Please refer to the following code implementation for details:

public static String decapitalize(String name) {
    if (name == null || name.length() == 0) {
        return name;
    }
    if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
                    Character.isUpperCase(name.charAt(0))){
        return name;
    }
    char chars[] = name.toCharArray();
    chars[0] = Character.toLowerCase(chars[0]);
    return new String(chars);
}

At this point, we easily understand the reasons for the two issues we encountered before: if a class name starts with two uppercase letters, the first letter will remain unchanged; in other cases, the first letter will be converted to lowercase by default. With our previous example, the bean name for SQLiteDataService should be the class name itself, while the bean name for CassandraDataService becomes lowercase (cassandraDataService).

Problem Fixes #

Now that we understand the rules for generating bean names, we can easily fix the two issues in the example. Let’s take the correction of the error in referencing the CassandraDataService type bean as an example, and provide two modification methods:

  1. Correcting the capitalization issue at the reference:
@Autowired
@Qualifier("cassandraDataService")
DataService dataService;
  1. Explicitly specify the bean name at the definition. We can keep the reference code unchanged and correct the issue by explicitly specifying the bean name of CassandraDataService as CassandraDataService.
@Repository("CassandraDataService")
@Slf4j
public class CassandraDataService implements DataService {
  // implementation omitted
}

Now, our program can accurately match the desired bean. If you are not familiar with the source code and do not want to be confused about whether the first letter should be uppercase or lowercase, I recommend using the second method to avoid confusion.

Case 3: Forgetting the class name when referencing an inner class Bean #

After solving case 2, does it mean that we can handle all explicit references to Beans and not make mistakes anymore? That is naive. We can continue with the previous case and add some additional requirements, such as defining an inner class to implement a new DataService. The code is as follows:

public class StudentController {
    @Repository
    public static class InnerClassDataService implements DataService{
        @Override
        public void deleteStudent(int id) {
          // implementation omitted
        }
    }
    // omitted other non-key code
}

In this case, we would naturally use the following way to directly reference this Bean:

@Autowired
@Qualifier("innerClassDataService")
DataService innerClassDataService;

Clearly, based on the experience from case 2, we start by using lowercase first letter to avoid the mistake in case 2. But does this code work correctly? In fact, it still gives an error saying “Bean not found”. Why?

Case analysis #

In fact, the situation we encountered is “how to reference an inner class Bean”. When analyzing case 2, I mentioned how the default Bean name is generated (i.e. AnnotationBeanNameGenerator#buildDefaultBeanName). At that time, we only focused on the code snippet that determines whether the first letter is lowercase, and before changing the first letter, there is a line of code that processes the class name, as shown below:

String shortClassName = ClassUtils.getShortName(beanClassName);

Let’s take a look at its implementation, referring to the ClassUtils#getShortName method:

public static String getShortName(String className) {
   Assert.hasLength(className, "Class name must not be empty");
   int lastDotIndex = className.lastIndexOf(PACKAGE_SEPARATOR);
   int nameEndIndex = className.indexOf(CGLIB_CLASS_SEPARATOR);
   if (nameEndIndex == -1) {
      nameEndIndex = className.length();
   }
   String shortName = className.substring(lastDotIndex + 1, nameEndIndex);
   shortName = shortName.replace(INNER_CLASS_SEPARATOR, PACKAGE_SEPARATOR);
   return shortName;
}

Clearly, assuming we have an inner class, such as the following class name:

com.spring.puzzle.class2.example3.StudentController.InnerClassDataService

After going through this method, what we actually get is the following name:

StudentController.InnerClassDataService

After the final transformation of changing the first letter using Introspector.decapitalize, we get the Bean name as follows:

studentController.InnerClassDataService

Therefore, it is natural that we cannot find the desired Bean by directly using innerClassDataService in the code of the case program.

Fixing the issue #

Through the analysis of the case, we quickly found the problem of referencing the inner class Bean, and we can fix it as follows:

@Autowired
@Qualifier("studentController.InnerClassDataService")
DataService innerClassDataService;

This reference may seem a bit strange, but it actually works. However, it is indeed not feasible to directly use innerClassDataService for referencing.

From this case, we can see that the comprehensiveness of our study of the source code determines the likelihood of making mistakes in the future. If we had studied the source code that handles the part of the class name change during the analysis of case 2, it would not have been easy to make this mistake. However, sometimes it is difficult for us to comprehensively and deeply study everything from the beginning. It always takes time and mistakes to learn and improve.

Key Takeaways #

After reviewing these three cases, we find that the direct result of these errors is the inability to find an appropriate Bean, but the reasons are different. For example, Case 1 is because there are too many provided Beans and it is impossible to decide which one to choose; Case 2 and Case 3 are because the specified names are not standardized, resulting in the inability to find the referenced Beans.

In fact, these errors are often prompted by some “smart” IDEs, but they may not be flagged by other mainstream IDEs that are not as intelligent. However, the tragedy is that even smart IDEs can sometimes have false positives, so it is not reliable to rely entirely on the IDE, after all, these errors can still be compiled.

In addition, our cases are simplified scenarios that are easy to identify and find problems, but real scenarios are often much more complex. For example, in the case of Case 1, our implementations of the same type may not appear in our own project code at the same time, but instead some implementations may appear in dependent Jar libraries. Therefore, you must have a solid understanding of the underlying source code implementation behind the cases in order to avoid these issues in complex scenarios.

Thought question #

We know that we can use @Qualifier to reference the desired bean, and we can also directly name the attribute after the name of the bean to reference it. These two ways are as follows:

// Approach 1: Name the property after the bean to be injected
@Autowired
DataService oracleDataService;

// Approach 2: Use @Qualifier to directly reference the bean
@Autowired
@Qualifier("oracleDataService")
DataService dataService;

So, for the internal class reference in case 3, do you think it is possible to achieve it using the first approach? For example, using the following code:

@Autowired
DataService studentController.InnerClassDataService;

Looking forward to seeing your answers in the comments section. See you in the next class!