Dependency Injection Internals: How @Autowired, Constructor Injection, and @Value Actually Resolve
Dependency Injection Internals
@Autowired is not a wiring instruction. It is a marker that triggers a resolution algorithm inside AutowiredAnnotationBeanPostProcessor. That algorithm matches by type, narrows by qualifier, breaks ties with @Primary, and falls back to bean name matching. Every ambiguous injection failure, every “expected single matching bean but found 2” stack trace, every silent wrong-bean injection traces back to a misunderstanding of this algorithm.
The SaaS backend has three DataSource beans: one per-tenant routing source, one for the shared analytics database, one for the job scheduler. Without precise injection control, Spring will wire the wrong database into the wrong service. Transactions will commit against the analytics store when they should hit the tenant database. You will not discover this in dev, because dev runs a single DataSource.
The Driver: AutowiredAnnotationBeanPostProcessor
AutowiredAnnotationBeanPostProcessor implements MergedBeanDefinitionPostProcessor and InstantiationAwareBeanPostProcessor. It runs at two points in the bean lifecycle.
Phase 1: Metadata collection. During postProcessMergedBeanDefinition(), the processor scans the bean class for @Autowired, @Value, and @Inject annotations on fields, methods, and constructors. It builds an InjectionMetadata object containing every injection point. This happens once per bean definition and is cached in injectionMetadataCache.
Phase 2: Injection execution. During postProcessProperties(), the processor iterates through the collected InjectionMetadata and resolves each dependency. Resolution is delegated to DefaultListableBeanFactory.resolveDependency(), which calls doResolveDependency(). This is where the algorithm lives.
// The call chain (simplified)
AutowiredAnnotationBeanPostProcessor.postProcessProperties()
-> InjectionMetadata.inject()
-> AutowiredFieldElement.inject() / AutowiredMethodElement.inject()
-> DefaultListableBeanFactory.resolveDependency()
-> DefaultListableBeanFactory.doResolveDependency()
The three injection styles (field, setter, constructor) all converge at doResolveDependency(). The resolution logic is identical. The only difference is how Spring discovers the injection point and how it delivers the resolved bean.
The Resolution Algorithm: doResolveDependency
DefaultListableBeanFactory.doResolveDependency() follows a fixed sequence:
Step 1: Check for a suggested value. If a @Value annotation is present, the processor resolves it through the StringValueResolver chain (property placeholders, SpEL expressions). This short-circuits the rest of the algorithm. @Value("${db.url}") never reaches the type-matching logic.
Step 2: Handle multiplicity types. If the injection point is a Collection, Map, ObjectProvider, or array, Spring calls resolveMultipleBeans(). This collects all beans matching the element type and returns the collection. A Map<String, DataSource> returns every DataSource bean keyed by bean name. An ObjectProvider<DataSource> wraps the resolution in a lazy provider.
Step 3: Find candidates by type. findAutowireCandidates() queries the bean factory for all bean definitions whose type is assignable to the required type. This uses ResolvableType for generic matching: a Repository<Order> will not match a Repository<Customer> even though both implement Repository. The type system preserves generics through ResolvableType.forField() and ResolvableType.forMethodParameter().
Step 4: Filter by qualifier. If the injection point has @Qualifier, Spring removes candidates that do not carry a matching @Qualifier value. Custom qualifier annotations (any annotation annotated with @Qualifier) participate in this step.
Step 5: Determine primary. If multiple candidates remain and one is marked @Primary, Spring selects it. If multiple candidates are @Primary, Spring throws NoUniqueBeanDefinitionException. @Primary is a tiebreaker, not an override.
Step 6: Fall back to name matching. If multiple candidates remain after qualifier and primary checks, Spring compares the injection point’s parameter name (or field name) against the bean names. A field named tenantDataSource will match a bean named tenantDataSource. This is why renaming a field can silently change which bean gets injected.
Step 7: Fail. If zero or multiple candidates remain, Spring throws. Zero candidates: NoSuchBeanDefinitionException. Multiple candidates: NoUniqueBeanDefinitionException.
Constructor Injection vs Field Injection: The Mechanism
Constructor injection is resolved during bean instantiation in AbstractAutowireCapableBeanFactory.createBeanInstance(). Spring calls AutowiredAnnotationBeanPostProcessor.determineCandidateConstructors() to pick the constructor. If a class has one constructor, Spring uses it (no @Autowired needed since Spring 4.3). If multiple constructors exist, only the one annotated with @Autowired is used. If no constructor is annotated and multiple exist, Spring falls back to the no-arg constructor.
Each constructor parameter is resolved through doResolveDependency() before the constructor is called. All dependencies must exist before the object is created. This is why constructor injection makes circular dependencies unresolvable at this level.
Field injection happens later. postProcessProperties() runs after the constructor returns. The bean exists as an incomplete object: constructed but not yet injected. Spring uses reflection (Field.set()) to write the resolved bean directly into the field. This is why field injection can participate in circular dependency resolution: the incomplete bean reference can be shared before injection completes.
Setter injection follows the same timing as field injection. The setter method is invoked via reflection after construction. The method parameter is resolved through the same doResolveDependency() path.
@Value Resolution
@Value processing happens inside doResolveDependency() at step 1. The embedded value (${property.name} or #{spelExpression}) is resolved through EmbeddedValueResolver, which delegates to PropertySourcesPlaceholderConfigurer for ${} expressions and StandardBeanExpressionResolver for #{} expressions.
// CORRECT: @Value with a default
@Value("${tenant.default.schema:public}")
private String defaultSchema;
// CORRECT: SpEL expression
@Value("#{tenantConfig.maxConnections * 2}")
private int connectionPoolSize;
The common failure: @Value("${missing.property}") with no default and no property defined. Spring throws IllegalArgumentException at injection time, not at startup scan time. The bean definition registers fine. The failure occurs during finishBeanFactoryInitialization when the bean is actually instantiated.
ObjectProvider: Deferred Resolution
ObjectProvider<T> wraps the resolution algorithm in a lazy container. Spring does not resolve the dependency when the containing bean is created. Resolution happens on the first call to getObject(), getIfAvailable(), or getIfUnique().
@Service
public class NotificationService {
private final ObjectProvider<EmailGateway> emailGateway;
public NotificationService(ObjectProvider<EmailGateway> emailGateway) {
this.emailGateway = emailGateway;
}
public void notifyTenant(String tenantId, String message) {
emailGateway.getIfAvailable(() -> new NoOpEmailGateway())
.send(tenantId, message);
}
}
ObjectProvider solves two problems. First, optional dependencies: getIfAvailable() returns null (or a default) instead of throwing when no bean matches. Second, ordering problems: if NotificationService is created before EmailGateway is registered, the ObjectProvider defers resolution until the gateway exists.
Internally, ObjectProvider is implemented by DependencyObjectProvider in DefaultListableBeanFactory. Each method call re-executes doResolveDependency(). There is no caching. If the bean factory state changes between calls, different results may be returned.
The Three-Level Singleton Cache
DefaultSingletonBeanRegistry maintains three concurrent hash maps:
// Level 1: Fully initialized singletons
Map<String, Object> singletonObjects
// Level 2: Early singleton references (exposed but not fully initialized)
Map<String, Object> earlySingletonObjects
// Level 3: Singleton factories (produces early references on demand)
Map<String, ObjectFactory<?>> singletonFactories
When getSingleton() is called during dependency resolution:
- Check
singletonObjects. If present, return it. Bean is fully initialized. - Check
earlySingletonObjects. If present, return it. Bean is partially initialized but its reference is stable. - Check
singletonFactories. If present, call the factory, move the result toearlySingletonObjects, remove fromsingletonFactories. Return the early reference. - The bean does not exist yet. Proceed to creation.
During creation, after the constructor returns but before populateBean() (field/setter injection), Spring registers an ObjectFactory in level 3. This factory returns the raw bean instance (or a proxy-wrapped version if AOP applies). If another bean requests this bean during its own creation, the factory produces the early reference, breaking the circular wait.
Constructor injection bypasses this mechanism entirely. The bean object does not exist until the constructor completes. There is no early reference to share. Two beans with mutual constructor dependencies will deadlock: A needs B to construct, B needs A to construct, neither can produce an early reference.
The Failure Mode
// BROKEN: Ambiguous injection with no qualifier
@Configuration
public class DataSourceConfig {
@Bean
public DataSource tenantDataSource(TenantResolver resolver) {
return new TenantRoutingDataSource(resolver);
}
@Bean
public DataSource analyticsDataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://analytics-db:5432/analytics")
.build();
}
}
@Service
public class OrderService {
@Autowired
private DataSource dataSource; // Which one? Spring cannot decide.
}
This throws NoUniqueBeanDefinitionException. Two candidates match type DataSource. No @Qualifier is present. No @Primary is declared. The field name dataSource matches neither bean name (tenantDataSource, analyticsDataSource). Resolution fails at step 7.
If you rename the field to tenantDataSource, it works silently via name fallback. This is fragile. A future rename breaks injection with no compile-time signal.
The Correct Pattern
// CORRECT: @Primary for the default, @Qualifier for the specific
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource tenantDataSource(TenantResolver resolver) {
return new TenantRoutingDataSource(resolver);
}
@Bean
public DataSource analyticsDataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://analytics-db:5432/analytics")
.build();
}
}
@Service
public class OrderService {
private final DataSource dataSource; // Gets tenantDataSource via @Primary
public OrderService(DataSource dataSource) {
this.dataSource = dataSource;
}
}
@Service
public class AnalyticsService {
private final DataSource dataSource;
public AnalyticsService(@Qualifier("analyticsDataSource") DataSource dataSource) {
this.dataSource = dataSource;
}
}
The resolution path for OrderService: type match finds two DataSource beans. No qualifier on the constructor parameter. @Primary selects tenantDataSource. Done.
The resolution path for AnalyticsService: type match finds two DataSource beans. @Qualifier("analyticsDataSource") filters to one candidate. Selected. @Primary on the other bean is irrelevant because qualifier matching already narrowed to a single candidate.
This pattern scales. The tenant routing datasource is the default. Every service that needs tenant-scoped data gets it without annotation. Services that need a specific datasource declare it explicitly with @Qualifier. The wiring intent is visible in the code. No silent fallback to field name matching. No ambiguity.
Prefer constructor injection. It makes dependencies explicit, enables immutability (final fields), and fails fast: if a dependency is missing, the bean cannot be constructed. Field injection hides dependencies, allows partial initialization, and participates in circular dependency resolution, which is a feature you should treat as a bug.