namalzi

← All posts

Digging into Spring's Source 5: How @Autowired Picks the Bean to Inject — Dependency Resolution

Spring · Published

Which bean Spring puts into an @Autowired field or constructor, how it finds that bean, and how it chooses when there are several — followed through the real source. Candidate lookup, the precedence of @Primary, @Qualifier and name matching, List and Map injection, the constructor selection rules, and what happens when nothing is found, each checked by running an example.

What this post covers

This is the fifth post in the "Digging into Spring's Source" series. Part 4 followed one getBean() call through the three steps of building a bean: instantiate, inject dependencies, initialise. For the second step I only said "a post-processor fetches another bean with getBean() and puts it where the @Autowired is" and moved on. This post opens up that sentence: how the bean to inject is found, and how one is chosen. On the map of Part 1 this is unit U03.

There are four questions to answer.

Item Details
Source Spring Framework 7.1.0-SNAPSHOT
Commit f447f3c310 (2026-10-01, main branch)
Modules read most spring-beans (and a little spring-context)
Classes read most AutowiredAnnotationBeanPostProcessor, DependencyDescriptor, DefaultListableBeanFactory, QualifierAnnotationAutowireCandidateResolver, ConstructorResolver

Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.

The examples in this post were run on the released Spring Framework 7.0.9 to check their output (I did not build and run the 7.1.0-SNAPSHOT this series reads). The output shown is exactly what came out.

The dependency-injection source has many branches for special cases. This post keeps only the main thread that someone seeing Spring for the first time can follow; what was left out is named at the end, under "If you want to go deeper".

Words to know first

Bean and container were explained in Part 1 and Part 3. A bean is an object Spring creates and manages; the container is what creates and keeps those beans.

Dependency injection (DI) means the container hands an object the other objects it needs. Part 4 introduced the two flavours: field injection (into a field) and constructor injection (as a constructor argument).

An injection point is one place the container has to fill. One @Autowired field, one method parameter, one constructor parameter: each is one injection point. In the source it is the class InjectionPoint, and the main character of this post, DependencyDescriptor, extends it.

A candidate is a bean that could go into a given injection point. Usually, matching the type is enough to be a candidate. This whole post is really about "collecting candidates and narrowing them down to one".

A qualifier is a marker on an injection point that narrows the candidates further, such as @Qualifier("english").

A post-processor (BeanPostProcessor) is an object that gets a turn while a bean is being created. Part 4 showed the hooks around initialisation; @Autowired is handled by a post-processor too. That post-processor is the first section of this post.

1. Who finds @Autowired — AutowiredAnnotationBeanPostProcessor

@Autowired is only an annotation; by itself it does nothing. The thing that looks for it and fills in a value is a post-processor called AutowiredAnnotationBeanPostProcessor. The name is long, but read it as "the bean post-processor for the Autowired annotation" and it makes sense.

Which annotations it looks for

The constructor of this class builds the list of annotations to look for.

	public AutowiredAnnotationBeanPostProcessor() {
		this.autowiredAnnotationTypes.add(Autowired.class);
		this.autowiredAnnotationTypes.add(Value.class);

		ClassLoader classLoader = AutowiredAnnotationBeanPostProcessor.class.getClassLoader();
		try {
			this.autowiredAnnotationTypes.add((Class<? extends Annotation>)
					ClassUtils.forName("jakarta.inject.Inject", classLoader));
			logger.trace("'jakarta.inject.Inject' annotation found and supported for autowiring");
		}
		catch (ClassNotFoundException ex) {
			// jakarta.inject API not available - simply skip.
		}
	}

spring-beans/src/main/java/org/springframework/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.java, lines 192–205

Three of them: Spring's @Autowired, @Value for injecting values, and the standard jakarta.inject.Inject, only if it is on the classpath. Whenever this post says "marked @Autowired", read it as "marked with one of these three".

This post-processor is registered automatically when a container that uses annotation configuration, such as AnnotationConfigApplicationContext, starts (AnnotationConfigUtils lines 164–168). In the same place, two things that appear in sections 3 and 4 are plugged into the container as well: ContextAnnotationAutowireCandidateResolver, which judges qualifiers, and AnnotationAwareOrderComparator, which decides ordering (lines 148–153).

When and how it gets its turn

Part 4's second step, populateBean(), calls postProcessProperties() on every registered post-processor (AbstractAutowireCapableBeanFactory lines 1442–1448). Our post-processor's version of that method is two lines: look up the list of injection points for this class (InjectionMetadata) and tell it to inject (lines 490–502).

The flow that fills an @Autowired field. populateBean calls the post-processor hook postProcessProperties; AutowiredAnnotationBeanPostProcessor runs AutowiredFieldElement.inject for each field in its cached InjectionMetadata. One field becomes a DependencyDescriptor and is passed to beanFactory.resolveDependency, which chooses a bean, fetches it with getBean, and the value is written with field.set. From one field to its value. The yellow box is a list built once per class; the blue box is the post-processor hook from Part 4.

The list of injection points is built by scanning the class (buildAutowiringMetadata, lines 547–607). It walks the fields and methods, collects those carrying one of the three annotations, then moves up to the superclass and repeats (do ... while (targetClass != null && targetClass != Object.class), lines 602–604). The result is cached under the bean name, so the reflective scan happens once per class (lines 527–545).

The code that actually fills one field, AutowiredFieldElement.resolveFieldValue(), is the starting point of this whole post (lines 756–791). Two lines matter. One field becomes one DependencyDescriptor (new DependencyDescriptor(field, this.required), line 757), and that descriptor is handed to the container's resolveDependency() (line 764). Method injection does the same for each parameter (line 864). The returned value is written into the field only if it is not null (lines 750–753).

The question form — DependencyDescriptor

A DependencyDescriptor is a form that says "this is what this place needs". It wraps a field or a parameter and answers the following.

Question Method Answer
Type getDependencyType() The field's type, or the parameter's type (lines 346–359)
Name getDependencyName() The field name, or the parameter name (lines 338–340)
Is it required? isRequired() The value of @Autowired(required = ...), and additionally false if the type is Optional or the element is @Nullable (lines 157–168)
Annotations present getAnnotations() @Qualifier, @Lazy and the like are read from here later
Fetch the chosen bean resolveCandidate() beanFactory.getBean(beanName, requiredType) (lines 224–235)

The last row is the link back to Part 4. Once the candidates have been found and one chosen, its name goes into getBean(), and if that bean does not exist yet, Part 4's creation journey starts right there.

Easy to get wrong

2. Finding candidates — from resolveDependency to findAutowireCandidates

The resolveDependency() that receives the form lives in DefaultListableBeanFactory, the class from Part 3 that implements everything a container does. It first separates out three special cases, Optional, ObjectProvider and @Lazy (section 6; jakarta.inject.Provider, if it is on the classpath, is separated out in the same place, lines 1648–1650), and hands everything else to doResolveDependency() (lines 1637–1659). It is the xxx() / doXxx() pairing we saw in Part 4.

doResolveDependency() is pleasant to read because the source labels it Step 1 through Step 6. Reduced to its skeleton:

	public @Nullable Object doResolveDependency(DependencyDescriptor descriptor, @Nullable String beanName,
			@Nullable Set<String> autowiredBeanNames, @Nullable TypeConverter typeConverter) throws BeansException {
		// ...
			// Step 1: pre-resolved shortcut for single bean match, for example, from @Autowired
			Object shortcut = descriptor.resolveShortcut(this);
			// ...
			// Step 2: pre-defined value or expression, for example, from @Value
			Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
			// ...
			// Step 3: shortcut for declared dependency name or qualifier-suggested name matching target bean name
			if (descriptor.usesStandardBeanLookup()) {
				// ...
			}

			// Step 4a: multiple beans as stream / array / standard collection / plain map
			Object multipleBeans = resolveMultipleBeans(descriptor, beanName, autowiredBeanNames, typeConverter);
			// ...
			// Step 4b: direct bean matches, possibly direct beans of type Collection / Map
			Map<String, Object> matchingBeans = findAutowireCandidates(beanName, type, descriptor);
			if (matchingBeans.isEmpty()) {
				// ...
				// Raise exception if nothing found for required injection point
				if (isRequired(descriptor)) {
					raiseNoMatchingBeanFound(type, descriptor.getResolvableType(), descriptor);
				}
				return null;
			}
			// ...
			// Step 5: determine single candidate
			if (matchingBeans.size() > 1) {
				autowiredBeanName = determineAutowireCandidate(matchingBeans, descriptor);
				if (autowiredBeanName == null) {
					if (isRequired(descriptor) || !indicatesArrayCollectionOrMap(type)) {
						// Raise exception if no clear match found for required injection point
						return descriptor.resolveNotUnique(descriptor.getResolvableType(), matchingBeans);
					}
					// ...
				}
				instanceCandidate = matchingBeans.get(autowiredBeanName);
			}
			// ...
			// Step 6: validate single result
			// ...
			if (instanceCandidate instanceof Class) {
				instanceCandidate = descriptor.resolveCandidate(autowiredBeanName, type, this);
			}
			return resolveInstance(instanceCandidate, descriptor, type, autowiredBeanName);
		// ...
	}

spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java, lines 1662–1776

The branches of doResolveDependency. A cached shortcut or a @Value is returned at once; if the field name or the @Qualifier value equals some bean's name, that bean is returned. For a List, Map or array every matching bean is collected. Otherwise findAutowireCandidates collects candidates: none gives NoSuchBeanDefinitionException, one is returned, two or more go to determineAutowireCandidate, and if no winner emerges a NoUniqueBeanDefinitionException is thrown. Steps 1 to 5. Green is an exit that returns a value; red is an exit that throws.

Read from the top:

  1. Steps 1 and 2: the fast lanes. If this injection point was resolved once before and the bean name remembered (the post-processor from section 1 caches it as a ShortcutDependencyDescriptor, lines 910–923), that name is used directly. With @Value, a converted value is returned instead of a bean. This post does not cover @Value.
  2. Step 3: same name, done. If there is a bean whose name is exactly the field name (or the @Qualifier value) and its type matches, that bean is returned without collecting candidates at all (lines 1696–1715). Two of the examples in section 3 actually finish here.
  3. Step 4a: places that take many. If the type is an array, List, Set or Map, every matching bean is collected and returned. That is section 4.
  4. Step 4b: collecting candidates. findAutowireCandidates() returns the beans that "match the type and qualify as candidates" as a map from name to object. If it is empty and the injection point is required, a NoSuchBeanDefinitionException is thrown.
  5. Step 5: choosing one. With two or more candidates, determineAutowireCandidate() chooses (section 3). If it cannot, resolveNotUnique() is called, which means a NoUniqueBeanDefinitionException (DependencyDescriptor lines 191–193).
  6. Step 6: fetching. The chosen name goes into resolveCandidate(), that is, getBean().

Qualifying as a candidate — findAutowireCandidates

findAutowireCandidates(), which collects the candidates, has three parts (lines 1959–2005).

  1. Collect names by type. BeanFactoryUtils.beanNamesForTypeIncludingAncestors() (lines 1962–1963) calls the getBeanNamesForType() from section 5-3 of Part 3 and, if there is a parent container, asks it too (BeanFactoryUtils lines 261–274). It is the lookup that walks every blueprint and compares types.
  2. Leave yourself out. isSelfReference() filters out the case where the bean being built finds itself as a candidate (line 1977, lines 2269–2273).
  3. Ask whether it qualifies. isAutowireCandidate() asks the AutowireCandidateResolver plugged in during section 1. The most basic qualification is the blueprint's autowireCandidate flag (AutowireCandidateResolver lines 45–46); a generic-type check and the @Qualifier check sit on top of it. Section 3 covers that.

Example 1: two beans of the same type

Two beans implement the Greeter interface and there is one place to put one.

// An example written for this post (not Spring's code)
public class NoUniqueExample {

    interface Greeter {
        String greet();
    }

    @Component
    static class KoreanGreeter implements Greeter {
        public String greet() { return "안녕하세요"; }
    }

    @Component
    static class EnglishGreeter implements Greeter {
        public String greet() { return "Hello"; }
    }

    @Component
    static class Welcome {
        @Autowired
        Greeter greeter;                      // two Greeter beans — which one goes here?
    }

    public static void main(String[] args) {
        try (var context = new AnnotationConfigApplicationContext()) {
            context.register(KoreanGreeter.class, EnglishGreeter.class, Welcome.class);
            context.refresh();
            // ...
        }
        catch (BeansException ex) {
            Throwable root = NestedExceptionUtils.getMostSpecificCause(ex);
            System.out.println(root.getClass().getSimpleName() + ": " + root.getMessage());
        }
    }
}

Run it and, after one WARN log line from the container, this is printed (every output in this post omits that one log line).

NoUniqueBeanDefinitionException: No qualifying bean of type 'ex.u03.NoUniqueExample$Greeter' available: expected single matching bean but found 2: noUniqueExample.KoreanGreeter,noUniqueExample.EnglishGreeter

Exactly the flow above. Two candidates are collected in Step 4b, Step 5 has nothing to choose by, and resolveNotUnique() throws. The "expected single matching bean but found 2" part is built by the exception's constructor (NoUniqueBeanDefinitionException lines 95–100); the "No qualifying bean of type ... available" prefix is added by its superclass NoSuchBeanDefinitionException (lines 99–103). "None" and "too many" are in an inheritance relationship (line 38).

Easy to get wrong

3. Choosing one of several — @Primary, names, @Qualifier

This is determineAutowireCandidate(), called when two or more candidates remain. The order is written in the source comments.

	protected @Nullable String determineAutowireCandidate(Map<String, Object> candidates, DependencyDescriptor descriptor) {
		Class<?> requiredType = descriptor.getDependencyType();
		// Step 1: check primary candidate
		String primaryCandidate = determinePrimaryCandidate(candidates, requiredType);
		if (primaryCandidate != null) {
			return primaryCandidate;
		}
		// Step 2a: match bean name against declared dependency name
		String dependencyName = descriptor.getDependencyName();
		// ...
		// Step 2b: match bean name against qualifier-suggested name
		String suggestedName = getAutowireCandidateResolver().getSuggestedName(descriptor);
		// ...
		// Step 3: check highest priority candidate
		String priorityCandidate = determineHighestPriorityCandidate(candidates, requiredType);
		if (priorityCandidate != null) {
			return priorityCandidate;
		}
		// Step 4: pick unique default-candidate
		String defaultCandidate = determineDefaultCandidate(candidates);
		if (defaultCandidate != null) {
			return defaultCandidate;
		}
		// Step 5: pick directly registered dependency
		// ...
		return null;
	}

spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java, lines 2038–2082

The order for choosing one of several candidates. If exactly one candidate is @Primary it wins; two or more throw. Otherwise a candidate whose name equals the field or parameter name or the @Qualifier value. Otherwise the candidate with the lowest @Priority value, with a tie throwing. Otherwise a single default-candidate or a directly registered dependency. If none applies, null is returned and that becomes a NoUniqueBeanDefinitionException. The selection order. @Primary comes before the name, and the name before @Priority.

Order Rule Source
① If exactly one candidate is @Primary, that one. Two or more throw determinePrimaryCandidate lines 2092–2128
② A candidate whose name (or alias) equals the field or parameter name, or the @Qualifier value matchesBeanName lines 2258–2261
③ The candidate with the lowest jakarta.annotation.Priority value. Two with the same lowest value throw determineHighestPriorityCandidate lines 2144–2179
④ If exactly one candidate remains a default-candidate, that one lines 2240–2252
⑤ A dependency object registered directly with the container lines 2074–2080

@Primary is an annotation, but what the chooser looks at is not the annotation; it is the blueprint's isPrimary() flag (lines 2188–2195). Reading the annotation and copying it into the blueprint is finished at the stage where blueprints are built (the subject of Part 8). The opposite of @Primary, @Fallback (since 6.2), is checked in the same method: if every other candidate is a @Fallback, the one that is not gets chosen (lines 2116–2126, Fallback lines 25–47).

Examples 2, 3 and 4: @Primary, @Qualifier, the field name

I ran example 1 three more times, changing only Welcome and the bean declarations. The rest of the code is the same as example 1, so only the changed parts are shown.

// An example written for this post (not Spring's code) — PrimaryExample
    @Component
    @Primary                                   // "when there are several, pick me"
    static class KoreanGreeter implements Greeter {
        public String greet() { return "안녕하세요"; }
    }
    // ...
    @Component
    static class Welcome {
        @Autowired
        Greeter greeter;
    }
KoreanGreeter -> 안녕하세요
// An example written for this post (not Spring's code) — QualifierExample
    @Component("korean")                       // the bean name is set explicitly
    static class KoreanGreeter implements Greeter {
        public String greet() { return "안녕하세요"; }
    }

    @Component("english")
    static class EnglishGreeter implements Greeter {
        public String greet() { return "Hello"; }
    }

    @Component
    static class Welcome {
        @Autowired
        @Qualifier("english")                  // only candidates with this name (or this qualifier)
        Greeter greeter;
    }
EnglishGreeter -> Hello
// An example written for this post (not Spring's code) — FieldNameExample
    @Component
    static class Welcome {
        @Autowired
        Greeter korean;                        // field name = bean name "korean"

        @Autowired
        Greeter english;                       // field name = bean name "english"
    }
korean  -> 안녕하세요
english -> Hello

All three were chosen as expected, but in the source they take different roads. @Primary has two candidates collected in Step 4b and is chosen by rule ① of the table. @Qualifier("english") and the field name korean, on the other hand, equal a bean name, so they finish in the Step 3 shortcut of section 2 before any candidates are collected (lines 1697–1704). The field name comes from getDependencyName(), the @Qualifier value from getSuggestedName() (QualifierAnnotationAutowireCandidateResolver lines 394–404). Rule ② of the table is a second chance for when that shortcut could not be taken.

@Qualifier is a filter more than a chooser

The value of @Qualifier does not have to be a bean name. You can put @Qualifier("fast") on the bean class as well and write @Qualifier("fast") at the injection point. Then there is no Step 3 shortcut; instead the filtering happens in the third part of section 2, "does it qualify". QualifierAnnotationAutowireCandidateResolver.isAutowireCandidate() looks for qualifier annotations on the injection point (@Qualifier, and jakarta.inject.Qualifier if it is on the classpath, lines 76–85) and checks whether the candidate bean carries the same qualifier (lines 149–168, lines 248–328). A bean whose qualifier does not match never becomes a candidate. So with a @Qualifier, the candidates are usually already down to one before Step 5. A value equal to the bean name (or an alias) also counts as a match (lines 311–315).

Easy to get wrong

korean -> EnglishGreeter / Hello

4. Taking all of them — List, Map, arrays and @Order

Sometimes you want all beans of a type rather than one. Declare the injection point as List<Greeter>, Set<Greeter>, Greeter[] or Map<String, Greeter>. Step 4a of section 2, resolveMultipleBeans(), handles it (lines 1793–1846). It looks at the type and takes one of four branches.

Type What happens Source
Array Candidates collected by element type, converted to an array, sorted lines 1811–1838
Collection, Set, List Candidates collected by the generic element type, converted; a List is sorted resolveMultipleBeanCollection lines 1863–1887
Map Only when the key type is String. Bean name → bean. Not sorted resolveMultipleBeanMap lines 1889–1911
Stream When received through ObjectProvider.stream() lines 1798–1810

The candidates are collected by the same findAutowireCandidates() as in section 2, except that the form is wrapped in a MultiElementDescriptor (lines 1870–1871), in which case addCandidateEntry() does not store a Class but calls getBean() for every candidate right away (lines 2014–2019). They are all going in anyway.

Sorting is done by the dependencyComparator plugged into the container (lines 1922–1931). As section 1 showed, an annotation-configured container has AnnotationAwareOrderComparator, which reads the @Order value or, failing that, the jakarta.annotation.Priority value (OrderUtils lines 118–128). Without either, the bean gets the lowest priority (Ordered.LOWEST_PRECEDENCE) (OrderComparator lines 128–135). Smaller values come first.

Example 6: List and Map, and @Order

// An example written for this post (not Spring's code)
public class ListOrderExample {

    interface Greeter {
        String greet();
    }

    @Component("korean")
    @Order(2)
    static class KoreanGreeter implements Greeter {
        public String greet() { return "안녕하세요"; }
    }

    @Component("english")
    @Order(1)
    static class EnglishGreeter implements Greeter {
        public String greet() { return "Hello"; }
    }

    @Component("japanese")                     // no @Order
    static class JapaneseGreeter implements Greeter {
        public String greet() { return "こんにちは"; }
    }

    @Component
    static class Welcome {
        @Autowired
        List<Greeter> greeters;                // every Greeter bean, in order

        @Autowired
        Map<String, Greeter> greetersByName;   // bean name -> bean
    }

    public static void main(String[] args) {
        try (var context = new AnnotationConfigApplicationContext()) {
            context.register(KoreanGreeter.class, JapaneseGreeter.class, EnglishGreeter.class, Welcome.class);
            // ... (print greeters in turn, then the keySet() of greetersByName)
        }
    }
}
EnglishGreeter -> Hello
KoreanGreeter -> 안녕하세요
JapaneseGreeter -> こんにちは
[korean, japanese, english]

The List comes out as @Order(1), @Order(2), none, which is not the registration order (korean, japanese, english). The Map's keys, however, are in registration order, because, as the table says, a Map is not sorted.

Example 6 as a picture. The beans are registered in the order korean (@Order(2)), japanese (no @Order), english (@Order(1)). findAutowireCandidates() collects the three Greeter beans. On the List/array side they are sorted by dependencyComparator into EnglishGreeter (1), KoreanGreeter (2), JapaneseGreeter (none, so last). On the Map side nothing is sorted, so the keys stay in registration order: [korean, japanese, english] The same three candidates go into the List in @Order order and into the Map in registration order. The order is taken from Example 6's actual output.

Easy to get wrong

5. Constructor injection — when there are several constructors

So far this has all been field injection, which happens after the object exists. Constructor injection happens inside Part 4's first step, instantiation. Two things have to be decided: which constructor to call, and what to pass as its arguments.

Which constructor — determineCandidateConstructors

Before picking a constructor, createBeanInstance() asks the post-processors "is there a constructor worth using?" (AbstractAutowireCapableBeanFactory lines 1215–1219, lines 1313–1325). The one that answers is, once again, the AutowiredAnnotationBeanPostProcessor of section 1. The rules are in the second half of its determineCandidateConstructors().

					if (!candidates.isEmpty()) {
						// Add default constructor to list of optional constructors, as fallback.
						if (requiredConstructor == null) {
							if (defaultConstructor != null) {
								candidates.add(defaultConstructor);
							}
							// ...
						}
						candidateConstructors = candidates.toArray(EMPTY_CONSTRUCTOR_ARRAY);
					}
					else if (rawCandidates.length == 1 && rawCandidates[0].getParameterCount() > 0) {
						candidateConstructors = new Constructor<?>[] {rawCandidates[0]};
					}
					else if (nonSyntheticConstructors == 2 && primaryConstructor != null &&
							defaultConstructor != null && !primaryConstructor.equals(defaultConstructor)) {
						candidateConstructors = new Constructor<?>[] {primaryConstructor, defaultConstructor};
					}
					else if (nonSyntheticConstructors == 1 && primaryConstructor != null) {
						candidateConstructors = new Constructor<?>[] {primaryConstructor};
					}
					else {
						candidateConstructors = EMPTY_CONSTRUCTOR_ARRAY;
					}

spring-beans/src/main/java/org/springframework/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.java, lines 419–446

The order for choosing a constructor. createBeanInstance asks the post-processors through determineCandidateConstructors. If constructors are marked @Autowired, those are the candidates (plus the default constructor as a fallback), and two or more with required=true throw. Otherwise, if there is exactly one constructor and it takes arguments, that one; if a record or Kotlin primary constructor exists, that one; otherwise null is returned, the default constructor is used for new, and without one the result is No default constructor found. The chosen constructors go to ConstructorResolver.autowireConstructor, which resolves every argument with resolveDependency. Choosing a constructor. The grey box is the road where the post-processor chose nothing.

Read from the top, there are four rules. candidates is the list of "constructors marked @Autowired" gathered by the loop just above (lines 375–418).

  1. If there are @Autowired constructors, those. Only one may have required = true (the default); two throw "Invalid autowire-marked constructor" (lines 397–412). Several with required = false are allowed, and then the default constructor, if there is one, is appended as a fallback.
  2. Otherwise, if there is exactly one constructor and it takes arguments, that one. This is why a class with a single constructor gets constructor injection without any @Autowired.
  3. Otherwise, the primary constructor of a record or a Kotlin class. This rule is the two else if branches in the code above: if there is a separate default constructor besides the primary one, both are returned together (lines 437–440); if the primary constructor is the only one, just that (lines 441–443). BeanUtils.findPrimaryConstructor() finds a record's canonical constructor or a Kotlin primary constructor (lines 278–296).
  4. Otherwise an empty array, meaning "nothing chosen" (null) (line 451). createBeanInstance() then simply calls new through the default constructor (line 1228). If there is none, the result is "No default constructor found" (SimpleInstantiationStrategy lines 98–102).

What goes into the arguments — ConstructorResolver

When the post-processor returns one or more constructors, ConstructorResolver.autowireConstructor() takes over (lines 135–311). With several candidates it sorts them, public first and the ones with more arguments first (line 209, AutowireUtils lines 64–73), and tries to fill the arguments of each in turn. A constructor whose arguments cannot be filled is skipped for the next one (lines 243–253). This is the "constructor with the greatest number of dependencies that can be satisfied" from the Javadoc of @Autowired (Autowired lines 36–39).

Filling one argument takes the same road as field injection. A DependencyDescriptor is made for each parameter (line 789) and resolveDependency() is called (lines 900–912). Everything in sections 2 to 4 applies to constructor arguments unchanged. That line is the one Part 4, section 6, pointed at when explaining why a constructor-injection cycle cannot be resolved.

Example 5: one constructor, two constructors

// An example written for this post (not Spring's code)
public class ConstructorExample {

    @Component
    static class Engine {
        public String toString() { return "Engine"; }
    }

    /** One constructor — used even without @Autowired. */
    @Component
    static class Car {
        final Engine engine;
        Car(Engine engine) { this.engine = engine; }
    }

    /** Two constructors, no @Autowired, a default constructor exists — the default one is used. */
    @Component
    static class Bike {
        Engine engine;
        Bike() { }
        Bike(Engine engine) { this.engine = engine; }
    }

    /** Two constructors, no @Autowired, no default constructor — nothing can be chosen. */
    @Component
    static class Truck {
        Engine engine;
        Truck(Engine engine) { this.engine = engine; }
        Truck(Engine engine, int wheels) { this.engine = engine; }
    }

    public static void main(String[] args) {
        try (var context = new AnnotationConfigApplicationContext()) {
            context.register(Engine.class, Car.class, Bike.class);
            // ... (print the engine field of Car and of Bike)
        }
        try (var context = new AnnotationConfigApplicationContext()) {
            context.register(Engine.class, Truck.class);
            // ... (on an exception, print its message and the root cause)
        }
    }
}

The output:

Car.engine  = Engine
Bike.engine = null
BeanCreationException: Error creating bean with name 'constructorExample.Truck': Failed to instantiate [ex.u03.ConstructorExample$Truck]: No default constructor found
  root cause: NoSuchMethodException: ex.u03.ConstructorExample$Truck.<init>()

Easy to get wrong

6. Nothing there, or later — required, Optional, ObjectProvider, @Lazy

Finally, "what if there is no bean to inject". In Step 4b of section 2, when there were no candidates, a true isRequired(descriptor) meant an exception and a false one meant null. The answer to isRequired has two layers. DependencyDescriptor.isRequired() checks whether the type is Optional or the element is @Nullable (lines 157–168), and on top of that QualifierAnnotationAutowireCandidateResolver.isRequired() looks at @Autowired(required = false) (lines 347–365). Either way, if the answer is "not strictly required", null comes back, and as section 1 showed, null is never written into the field, so the field keeps whatever value it was declared with.

Optional, ObjectProvider and @Lazy mean more than "may be absent": they mean "do not look it up now", so resolveDependency() separates them by type before doResolveDependency() is reached. Optional goes to lines 1641–1642, ObjectFactory or ObjectProvider to lines 1644–1646, a @Lazy injection point to lines 1651–1657 (the lines 1648–1650 in between are jakarta.inject.Provider), and everything else to section 2's doResolveDependency() (line 1658).

The branches of resolveDependency. If the type of the injection point is Optional, the dependency is resolved with required=false and wrapped in an Optional; for ObjectProvider or ObjectFactory a DependencyObjectProvider is injected that resolves only on getObject(); with @Lazy a proxy is injected that resolves on the first method call. Any other type goes to doResolveDependency, and when no candidate is found a required point throws NoSuchBeanDefinitionException while required=false or @Nullable yields null and the field is left untouched. The three grey boxes are the roads where something other than the real bean is injected.

Declaration What is injected When the bean is really looked up
Optional<T> An Optional holding the bean, or an empty one. Section 2's flow is run with required = false (lines 2340–2360) At injection time
ObjectProvider<T> A small object called DependencyObjectProvider (lines 2509–2527). getObject() insists on finding one (lines 2530–2536); getIfAvailable() returns null when there is none; getIfUnique() returns null even when there are several (lines 2559–2625) When one of those methods is called
@Lazy T A proxy of type T that looks the bean up on the first method call and delegates to it (ContextAnnotationAutowireCandidateResolver lines 53–55, lines 91–108) When a method of the proxy is first called

All three methods of ObjectProvider reuse section 2's doResolveDependency(). Each tweaks the form (DependencyDescriptor) with an anonymous subclass so that isRequired() returns false, or resolveNotUnique() returns null instead of throwing, and then calls the same resolver (lines 2565–2575, lines 2599–2612).

@Lazy is the thing Part 4 named at the end as "a way to break a cycle". Put @Lazy on one constructor argument of a constructor-injection cycle and, instead of looking the other bean up at instantiation time, a proxy is injected, so the exception from Part 4, section 6, does not occur (this comes from reading the source; I did not run an example for it in this post). The proxy is built with ProxyFactory (lines 100–107), and what happens inside it is the subject of Part 10. This class lives in spring-context rather than spring-beans precisely because it needs the proxy machinery from spring-aop.

Easy to get wrong

If you want to go deeper

Only the names and locations of what this post left out. Step 2, which resolves and converts @Value values and expressions — the expression language, SpEL, is covered in Part 15 — (lines 1675–1694, continued by Part 15 on SpEL). GenericTypeAwareAutowireCandidateResolver, which matches generic types too (from line 46; it needs ResolvableType from Part 13). Custom qualifier annotations that carry @Qualifier as a meta-annotation (checkQualifiers lines 176–221). The second attempt, the fallback match, made when the first candidate search finds nothing (lines 1981–1991, DependencyDescriptor lines 309–320). The older style of autowire="byName" and "byType" driven by blueprint settings rather than annotations (populateBean lines 1425–1437). registerResolvableDependency, which registers non-bean objects as injection candidates, and the code that puts them into the candidate map (lines 1965–1975). Receiving the InjectionPoint itself as a constructor argument (ConstructorResolver lines 903–909). The cache that reuses a resolved result from the second time on (ShortcutDependencyDescriptor, lines 910–923). And the AOT path that generates injection code ahead of time (AotContribution, from line 929, Part 16).

Recap

Concept In one line
AutowiredAnnotationBeanPostProcessor The post-processor that finds @Autowired, @Value and @Inject and injects. Fields in populateBean, constructor choice in createBeanInstance
InjectionMetadata The list of injection points, built once per class and cached. Superclass entries come first
DependencyDescriptor The form for one injection point: type, name, required, annotations. The chosen bean is fetched with getBean()
resolveDependency Separates Optional, ObjectProvider and @Lazy first, then goes to doResolveDependency
doResolveDependency Shortcut → name match → collection → collect candidates → choose one → getBean
findAutowireCandidates Collect names by type, drop yourself, ask whether each qualifies (autowireCandidate flag, generics, @Qualifier)
determineAutowireCandidate Chooses one by @Primary → name match → @Priority → default-candidate, in that order
@Qualifier A filter at the qualification stage. When its value is a bean name it also takes the shortcut
Collection injection Every type-matching bean. List and arrays are sorted by @Order; a Map keeps registration order
Constructor choice @Autowired ones → the only constructor → record/Kotlin primary constructor → default constructor
Nothing found NoSuchBeanDefinitionException if required; otherwise null, and the field is left alone
Several found NoUniqueBeanDefinitionException if none can be chosen, regardless of required

What to look at next

This post opened the place where Part 4 said only "fetched with getBean()". One field became a form, the form collected candidates, and the candidates were narrowed down to one.

We saw the @Autowired post-processor get its turn in two places, populateBean and createBeanInstance, and Part 4 showed the hooks around initialisation. The next question is how many places there are to get a turn while the container builds a bean, and in what order they are called. Part 6: Lifecycle Callbacks and PostProcessors — Where You Can Hook into the Container sorts out the variants of BeanPostProcessor and the order in which @PostConstruct, InitializingBean and the Aware interfaces are called.

More posts