The Resolution Algorithm: Type Matching, Qualifiers, and Primary Beans
The Resolution Algorithm
DefaultListableBeanFactory.doResolveDependency() is one method. About 120 lines of code. Every @Autowired field, every constructor parameter, every @Inject point in your application passes through it. Understanding this method means you can predict the outcome of any injection scenario before running the application.
The SaaS backend has a concrete problem: multiple DataSource beans for tenant routing, analytics, and job scheduling. Multiple NotificationSender implementations for email, SMS, and push notifications. Multiple TenantResolver strategies for header-based, subdomain-based, and JWT-based tenant identification. Every one of these requires Spring to make a choice. The resolution algorithm defines how that choice is made.
Step 1: Suggested Value Resolution
Before type matching begins, doResolveDependency() checks for an embedded value. If the DependencyDescriptor carries a @Value annotation, the value string is resolved through resolveEmbeddedValue() and evaluateBeanDefinitionString():
Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
if (value != null) {
// Resolve ${...} placeholders, then #{...} SpEL
value = resolveEmbeddedValue((String) value);
value = evaluateBeanDefinitionString((String) value, bd);
// Type-convert the resolved string to the target type
value = typeConverter.convertIfNecessary(value, type);
return value;
}
This entire branch short-circuits. If @Value is present, Spring never searches for candidate beans. This is why @Autowired and @Value on the same field produce undefined behavior. Do not combine them.
Step 2: Multiplicity Handling
If the injection point declares a collection type, Spring diverts to resolveMultipleBeans():
| Target Type | Behavior |
|---|---|
T[] | All beans of type T, ordered by @Order / Ordered |
Collection<T> | All beans of type T in a LinkedHashSet or ArrayList |
Map<String, T> | All beans of type T, keyed by bean name |
ObjectProvider<T> | Lazy wrapper, resolves on demand |
Optional<T> | Single bean or Optional.empty() |
The SaaS backend uses this for notification dispatch:
@Service
public class TenantNotificationService {
private final Map<String, NotificationSender> senders;
public TenantNotificationService(Map<String, NotificationSender> senders) {
this.senders = senders; // {"emailSender": ..., "smsSender": ..., "pushSender": ...}
}
public void notify(String tenantId, String channel, String message) {
NotificationSender sender = senders.get(channel + "Sender");
if (sender == null) {
throw new UnsupportedChannelException(channel);
}
sender.send(tenantId, message);
}
}
Spring collects all NotificationSender beans and builds the map automatically. No manual registration. No factory pattern. The bean names become the map keys.
Step 3: findAutowireCandidates and Type Matching
For single-bean injection, doResolveDependency() calls findAutowireCandidates(beanName, type, descriptor). This method:
- Calls
BeanFactoryUtils.beanNamesForTypeIncludingAncestors()to find all bean names whose type matches or is assignable to the required type. - Adds any explicitly registered
resolvableDependencies(theApplicationContextitself, theBeanFactory, theEnvironment). - Filters each candidate through
isAutowireCandidate(), which checks autowire-candidate flags and qualifier annotations.
Type matching uses ResolvableType, Spring’s generic-aware type representation. This is critical for repositories:
public interface TenantRepository<T> {
T findByTenantId(String tenantId);
}
@Repository
public class OrderTenantRepository implements TenantRepository<Order> { }
@Repository
public class CustomerTenantRepository implements TenantRepository<Customer> { }
@Service
public class OrderService {
// ResolvableType preserves the generic: TenantRepository<Order>
// Only OrderTenantRepository matches, not CustomerTenantRepository
private final TenantRepository<Order> orderRepo;
public OrderService(TenantRepository<Order> orderRepo) {
this.orderRepo = orderRepo;
}
}
ResolvableType.forMethodParameter() captures the full generic signature of the constructor parameter. When comparing against OrderTenantRepository, Spring resolves its implemented interfaces and finds TenantRepository<Order>. The generic argument Order matches. CustomerTenantRepository implements TenantRepository<Customer>. The generic argument does not match. One candidate remains. No ambiguity.
Without generic preservation (as in raw type erasure), both repositories would match TenantRepository, and Spring would fall through to qualifier/name matching. ResolvableType prevents this.
Step 4: Qualifier Filtering
If the injection point carries @Qualifier, Spring filters candidates through QualifierAnnotationAutowireCandidateResolver.isAutowireCandidate(). Each candidate’s bean definition is checked for a matching qualifier value.
@Configuration
public class DataSourceConfig {
@Bean
@Qualifier("tenant")
public DataSource tenantDataSource(TenantResolver resolver) {
return new TenantRoutingDataSource(resolver);
}
@Bean
@Qualifier("analytics")
public DataSource analyticsDataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://analytics-db:5432/analytics")
.build();
}
@Bean
@Qualifier("scheduler")
public DataSource schedulerDataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://scheduler-db:5432/scheduler")
.build();
}
}
@Service
public class AnalyticsIngestionService {
private final DataSource dataSource;
public AnalyticsIngestionService(@Qualifier("analytics") DataSource dataSource) {
this.dataSource = dataSource;
}
}
Three DataSource beans exist. The @Qualifier("analytics") on the constructor parameter filters candidates to the one bean whose definition carries @Qualifier("analytics"). One candidate survives. Injection succeeds.
Custom Qualifier Annotations
String-based qualifiers are fragile. Typos are invisible at compile time. The stronger approach: custom qualifier annotations.
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier // This meta-annotation makes it a qualifier
public @interface TenantScoped { }
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface Analytics { }
@Bean
@TenantScoped
public DataSource tenantDataSource(TenantResolver resolver) {
return new TenantRoutingDataSource(resolver);
}
@Bean
@Analytics
public DataSource analyticsDataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://analytics-db:5432/analytics")
.build();
}
@Service
public class AnalyticsIngestionService {
private final DataSource dataSource;
public AnalyticsIngestionService(@Analytics DataSource dataSource) {
this.dataSource = dataSource;
}
}
Now @Analytics is a type-checked qualifier. Rename it, and the compiler catches every usage. Typo in @Qualifier("analitics") compiles and fails at runtime. Typo in @Analitics does not compile.
QualifierAnnotationAutowireCandidateResolver detects custom qualifiers by checking whether the annotation is itself annotated with @Qualifier. The matching logic compares annotation types and attribute values. Custom qualifiers with attributes (e.g., @DataSourceFor(region = "eu")) work as expected: all attributes must match.
Step 5: @Primary as Tiebreaker
If qualifier filtering does not reduce candidates to one, and multiple candidates remain, Spring checks for @Primary:
protected String determinePrimaryCandidate(
Map<String, Object> candidates, Class<?> requiredType) {
String primaryBeanName = null;
for (Map.Entry<String, Object> entry : candidates.entrySet()) {
if (isPrimary(entry.getKey(), entry.getValue())) {
if (primaryBeanName != null) {
// Two @Primary beans of the same type
throw new NoUniqueBeanDefinitionException(requiredType, candidates.keySet());
}
primaryBeanName = entry.getKey();
}
}
return primaryBeanName;
}
Rules are strict. Exactly one @Primary bean of a given type is allowed. Two @Primary beans of the same type cause NoUniqueBeanDefinitionException. Zero @Primary beans means this step produces no result and resolution falls through.
@Primary does not override @Qualifier. If an injection point has @Qualifier("analytics"), the @Primary bean is irrelevant. Qualifier filtering already narrowed the candidates. @Primary only participates when no qualifier is specified and multiple candidates remain.
Step 6: Bean Name Fallback
If no qualifier match and no @Primary resolved the ambiguity, Spring compares the field name or parameter name against candidate bean names:
protected String matchesBeanName(String beanName, String candidateName) {
return candidateName != null &&
(candidateName.equals(beanName) ||
ObjectUtils.containsElement(getAliases(candidateName), beanName));
}
The field name analyticsDataSource matches a bean named analyticsDataSource. This is convenient but dangerous.
Rename the field to dataSource during a refactor. No compile error. No test failure if the test uses a single DataSource. In production, with three DataSource beans, the name no longer matches any candidate. NoUniqueBeanDefinitionException at startup.
Bean name fallback is a last resort, not a strategy.
The Failure Mode
// BROKEN: Injecting List<DataSource> when only specific ones are wanted
@Service
public class TenantMigrationService {
private final List<DataSource> dataSources;
public TenantMigrationService(List<DataSource> dataSources) {
this.dataSources = dataSources; // Gets ALL DataSource beans: tenant, analytics, scheduler
}
public void migrateSchema(String tenantId) {
// Which DataSource is the tenant one? Index 0? Index 1?
// Order depends on bean registration order, which depends on
// classpath scanning order, which is not guaranteed.
dataSources.get(0).getConnection(); // Wrong. Maybe analytics. Maybe scheduler.
}
}
resolveMultipleBeans() collects every DataSource bean. The list order is determined by bean registration order, influenced by @Order if present, but otherwise dependent on classpath scanning sequence. Indexing into the list is a defect. The order may change between restarts, between environments, between Spring Boot versions.
The Correct Pattern
// CORRECT: Custom qualifier annotation for tenant-specific injection
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface TenantScoped { }
@Configuration
public class DataSourceConfig {
@Bean
@TenantScoped
@Primary
public DataSource tenantDataSource(TenantResolver resolver) {
return new TenantRoutingDataSource(resolver);
}
@Bean
@Analytics
public DataSource analyticsDataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://analytics-db:5432/analytics")
.build();
}
@Bean
@Qualifier("scheduler")
public DataSource schedulerDataSource() {
return DataSourceBuilder.create()
.url("jdbc:postgresql://scheduler-db:5432/scheduler")
.build();
}
}
@Service
public class TenantMigrationService {
private final DataSource tenantDataSource;
private final JdbcTemplate tenantJdbc;
public TenantMigrationService(@TenantScoped DataSource tenantDataSource) {
this.tenantDataSource = tenantDataSource;
this.tenantJdbc = new JdbcTemplate(tenantDataSource);
}
public void migrateSchema(String tenantId) {
tenantJdbc.execute("ALTER SCHEMA " + tenantId + " ...");
}
}
Resolution path: type match finds three DataSource beans. @TenantScoped is a custom qualifier (meta-annotated with @Qualifier). QualifierAnnotationAutowireCandidateResolver checks each candidate for @TenantScoped. Only tenantDataSource carries it. One candidate remains. Injected.
The intent is explicit. The qualifier is type-safe. The resolution path is deterministic regardless of classpath scanning order, bean registration order, or how many new DataSource beans are added to the context. No service needs to know about the other data sources. Each injection point declares exactly which one it needs.