1. Two interface implementations: the norm and Spring's complaint
As long as we have one CourseCatalogRepository, Spring is happy: "I see the type — I see the only bean — I inject it." But as soon as a second bean of the same type appears in the project (you added an alternative implementation, connected a library, or just made a training "backup" version), the container stops guessing. It honestly says: "I'm not psychic."
Let's imagine that we have a repository interface:
import java.util.List;
// Repository contract: return the course catalog as a list of cards
public interface CourseCatalogRepository {
List<CourseCard> findAll();
}
And for learning purposes we made two implementations — a regular one and an "empty" one (for example, to quickly check how the service behaves on an empty catalog without changing the service code):
import java.util.List;
import org.springframework.stereotype.Repository;
@Repository // Register the repository bean in the Spring container
public class EmptyCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Stub: always return an empty catalog
}
}
import java.util.List;
import org.springframework.stereotype.Repository;
@Repository // Another bean of the same type (this creates ambiguity)
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // For the example, return empty (in real life this would be an in-memory list)
}
}
And the service looks completely innocent:
import org.springframework.stereotype.Service;
@Service // The service will be assembled through DI (constructor injection)
public class CourseCatalogService {
public CourseCatalogService(CourseCatalogRepository repository) {
// Spring has to choose one specific CourseCatalogRepository bean
}
}
And here Spring will say something like the following (the text may differ, but the meaning is the same):
NoUniqueBeanDefinitionException:
expected single matching bean but found 2
It is important to feel the philosophy: this is not a "Spring error". It is protection from accidental behavior. If the container starts choosing "one of the two", your application will work "like this today, like that tomorrow" — and that is no longer development, it is a lottery.
2. How Spring chooses a bean: a simple mental model
Now we need to build a clear model: what rules does Spring use when it sees a dependency "by type"? The good news: the basic algorithm is not complicated. The bad news: if you do not know it, you will treat symptoms (@Qualifier "just in case") and grow a configuration jungle.
For single-value injection (when the constructor needs exactly one T, not a collection), you can keep the following scheme in mind:
flowchart TD
A["Bean of type T is needed"] --> B{"Candidates by type"}
B -->|0| C["Error: no bean<br/>or Optional.empty()"]
B -->|1| D["Inject the only bean"]
B -->|>1| E{"Is there a @Qualifier?"}
E -->|yes| F["Keep candidates with that qualifier/name"]
E -->|no| G{"Is there a @Primary?"}
G -->|yes| H["Take primary"]
G -->|no| I["Otherwise: ambiguity"]
F --> J{"After filter, 1?"}
J -->|yes| D
J -->|no| I
I --> K["Error: NoUniqueBeanDefinitionException"]
In practice, there is another nuance: sometimes Spring can try to match the constructor parameter name with the bean name. But this depends on whether parameter names are preserved in the bytecode (and on a number of compilation settings). For a beginner, this is too fragile a foundation, so we keep the basic set of tools: @Primary, @Qualifier, Optional<T>, List<T>.
To avoid confusion, it is useful to remember this as a table (think of it as a mini cheat sheet for every day):
| Situation in the application | What to write in the constructor | What Spring will do |
|---|---|---|
| There is one implementation | T dep | Inject it without hesitation |
| There are several implementations, one is "default" | T dep + @Primary on one bean | Choose the primary one |
| There are several implementations, and you need a specific one | @Qualifier("...") T dep | Take the one with the needed qualifier/name |
| The implementation may be missing | Optional<T> dep | Return Optional.empty() instead of failing |
| You need all implementations | List<T> deps | Return a list of all beans of this type |
Next we will go through these tools one by one — with the feeling that you are not "learning annotations", but giving the container a hint about how exactly to build the dependency graph.
3. @Primary: a "default" bean when one is truly the main one
@Primary is not a "magic button to make everything work". It is a way to honestly tell the container: "yes, there are several implementations, but this one is the main default option." Imagine you ordered coffee "as usual". @Primary is the barista who remembers that "usual" for you means cappuccino, not a lavender surprise latte.
Let's go back to our CourseCatalogRepository. If we really want to consider InMemoryCourseCatalogRepository the main implementation, we mark it with @Primary:
import java.util.List;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Repository;
@Repository // Register the repository bean
@Primary // Mark this bean as the default one if there are several candidates
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // For the example: return an empty catalog
}
}
Now the service constructor can remain as clean as possible:
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
public CourseCatalogService(CourseCatalogRepository repository) {
// If there are multiple implementations, Spring will choose the one marked @Primary
}
}
And Spring will choose the "main" one.
There is an important psychological point here. @Primary is good when you really have a main implementation and a helper one (stub, experiment, temporary, training). But if you have two equally valid strategies and the choice depends on the scenario, @Primary turns into a "hidden policy" that is easy to forget. In that case, it is often more honest to use @Qualifier in the places where you need specifics.
4. @Qualifier: choose a specific bean and stop relying on luck
@Qualifier is a way to say: "I do not just need CourseCatalogRepository, I need that one." Here it is important not to turn the code into a collection of strings. Yes, @Qualifier often looks like a string, and yes — strings love typos. But it is still better than implicit ambiguity and an application crash at startup.
The most direct path is to refer to the bean name. If a bean is created via a stereotype annotation (@Component, @Service, @Repository), then by default its name is lowerCamelCase from the class name. That means EmptyCourseCatalogRepository becomes emptyCourseCatalogRepository.
Then the constructor will look like this:
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
public CourseCatalogService(
@Qualifier("emptyCourseCatalogRepository") CourseCatalogRepository repo) {
// Explicitly ask Spring: take exactly the bean with this name/qualifier
}
}
It looks simple, but there is a nuance: you start depending on a name that can be accidentally broken by a class refactoring. That is why it is often more convenient to assign a qualifier explicitly, without tying it to the class name. This is done like this: put @Qualifier on the bean itself (on the class), and use the same qualifier in the injection point.
import java.util.List;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;
@Repository // Create the repository bean
@Qualifier("empty") // Give it a "role": this is the empty implementation
public class EmptyCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Empty catalog as an explicit strategy/option
}
}
And now the injection looks more stable:
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
public CourseCatalogService(@Qualifier("empty") CourseCatalogRepository repo) {
// Take the specific implementation by the "empty" label
}
}
Notice that here @Qualifier("empty") is no longer just an "accidental string". It is your little "role label". And you can keep it stable even if you rename the class.
Another nuance: @Primary and @Qualifier solve different problems. @Primary chooses the "default", while @Qualifier chooses the "specific" one. When you use @Qualifier, @Primary usually no longer matters, because you have explicitly specified what you need.
5. Optional<T>: a dependency that may be absent
Sometimes a component is not required. Not "we will add it later", but by design: the service must be able to live without it. In plain Java, this is usually handled with null and a prayer of "just do not forget to check". In Spring, you can do this more cleanly: tell the container that the dependency is optional via Optional<T>.
Let's imagine a small infrastructure interface that knows how to print a nice banner. In some environments it exists, in others it does not (for example, you do not want this banner every time on a local startup):
// Infrastructure component: it may be connected, or it may be absent
public interface CatalogBannerWriter {
void writeBanner(String title);
}
A service that uses the banner can honestly express the dependency as optional:
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public class StartupSummaryService {
private final Optional<CatalogBannerWriter> bannerWriter;
public StartupSummaryService(Optional<CatalogBannerWriter> bannerWriter) {
// If there is no bean, Spring will supply Optional.empty() instead of failing
this.bannerWriter = bannerWriter;
}
}
And usage becomes safe:
public void printBanner(String title) {
// Print the banner only if the writer is actually present in the context
bannerWriter.ifPresent(w -> w.writeBanner(title));
}
The key idea: Optional<T> is a contract at the constructor level. It tells the class reader: "this component may exist, or it may not; the class is ready for both variants." And Spring supports this behavior: if there is no bean, it does not crash the application, but supplies Optional.empty().
Do not confuse this: Optional<T> does not create a bean. It only makes absence acceptable. If you want the bean to always be there, then Optional is not needed here: let the dependency be mandatory.
6. List<T>: inject "all beans of this type"
Sometimes we need not one component, but a set of same-type components: a chain of rules, a set of checks, several formatting strategies, a small pipeline. For this, Spring can inject collections, and the most understandable option for starters is List<T>.
Let's imagine that we have simple rules that can filter a course card. We are not building a "big rule architecture" here, a tiny interface is enough:
// Small "rule" contract: can the card continue down the pipeline?
public interface CourseCardRule {
boolean test(CourseCard card);
}
Two small rules (each one is a separate bean):
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // Register the rule as a bean
@Order(10) // Explicitly set the order in the List<CourseCardRule>
public class PublishedOnlyRule implements CourseCardRule {
public boolean test(CourseCard card) {
return card.isPublished(); // Pass only published courses
}
}
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // The second rule is also a bean
@Order(20) // Will be applied after PublishedOnlyRule
public class FeaturedOnlyRule implements CourseCardRule {
public boolean test(CourseCard card) {
return card.isFeatured(); // Pass only "featured" courses
}
}
And now the service can receive all the rules at once:
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
private final List<CourseCardRule> rules;
public CourseCatalogService(List<CourseCardRule> rules) {
// Spring will collect all CourseCardRule beans here (or an empty list if there are none)
this.rules = rules;
}
}
And apply them with "everyone must agree":
public boolean isAllowed(CourseCard card) {
// "AND logic": the card is allowed if all rules return true
return rules.stream().allMatch(r -> r.test(card));
}
Here it is useful to know two behaviors that often surprise beginners. First, if there are no CourseCardRule beans at all, Spring will supply an empty list, not crash the application. Second, the order of elements in the list can be controlled with @Order (or the Ordered interface), otherwise the order may be unclear — and that is exactly the kind of small detail from which "why does it sometimes work differently?" grows later.
7. Mini case: primary, qualifier, optional and list
Let's put everything into one picture so that it does not look like a "zoo of annotations". In our catalog-service we want to see several different scenarios: choose the main repository by default; inject a specific implementation in one place; let the service live without a banner; and get a set of rules as a list. In real life these cases often appear gradually, but here we will show them compactly.
From the perspective of the dependency graph, this can be represented like this:
flowchart LR
S["CourseCatalogService"] --> R["CourseCatalogRepository<br/>one of several"]
S --> L["List<CourseCardRule><br/>all rules"]
SS["StartupSummaryService"] --> O["Optional<CatalogBannerWriter><br/>may be absent"]
Now let's show the most important part: what this looks like in code so that the constructor itself lets you "read" the wiring rules.
If the project has two repositories, and we want a default one, put @Primary on the main one:
import java.util.List;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Repository;
@Repository // Main repository bean
@Primary // Default choice for single-value injection
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Example: in real code this would read from memory
}
}
And leave the alternative without primary:
import java.util.List;
import org.springframework.stereotype.Repository;
@Repository // Alternative bean of the same type
public class EmptyCourseCatalogRepository implements CourseCatalogRepository {
public List<CourseCard> findAll() {
return List.of(); // Explicitly empty strategy
}
}
Then the main service can receive the repository "by default" and the list of rules at the same time (notice: no @Qualifier is needed here):
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
private final CourseCatalogRepository repository;
private final List<CourseCardRule> rules;
public CourseCatalogService(CourseCatalogRepository repository, List<CourseCardRule> rules) {
// repository: the @Primary repository will be chosen
// rules: the list of all CourseCardRule beans will be collected (in the specified @Order order)
this.repository = repository;
this.rules = rules;
}
}
But if somewhere you need to forcefully take the "empty" repository (for example, you are making a separate service for diagnostics or a very specific scenario), then you use @Qualifier. This is a targeted operation, not a "put qualifiers everywhere" style.
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class EmptyCatalogPreviewService {
private final CourseCatalogRepository repository;
public EmptyCatalogPreviewService(
@Qualifier("emptyCourseCatalogRepository") CourseCatalogRepository repository) {
// Here we intentionally choose the specific implementation, ignoring @Primary
this.repository = repository;
}
}
And separately we show how to make a truly optional dependency:
import java.util.Optional;
import org.springframework.stereotype.Service;
@Service
public class StartupSummaryService {
private final Optional<CatalogBannerWriter> bannerWriter;
public StartupSummaryService(Optional<CatalogBannerWriter> bannerWriter) {
// Safe: absence of a bean is turned into Optional.empty()
this.bannerWriter = bannerWriter;
}
}
As a result, we get an important "human" rule: single-value dependencies are solved with @Primary and @Qualifier, "may be absent" is expressed with Optional, and "need everything" is expressed with List. And the best part is that all of this is visible right in constructor signatures, without running the application and without searching the project.
8. Choosing among candidates and conditional registration are not the same thing
It is easy here to mix up two different tasks. @Primary and @Qualifier are needed when both beans already exist in the current ApplicationContext, and the container only needs to decide which one to give to a specific injection point. But there is another situation: you may not want to keep both implementations active at the same time at all. For example, in one runtime you need one repository, and in another — a different one.
Then the issue is solved earlier — not in the constructor, but at the bean registration stage. In other words, you are not choosing between two already existing candidates, but making sure that only the needed candidate appears in the current environment. For the current wiring, it is enough to keep a simple boundary in mind:
- @Primary / @Qualifier — selection among already registered beans;
- profile/property conditions — the decision about which beans will enter the context at all;
- if you mix these two levels, the dependency graph quickly becomes confusing.
For clean wiring, this is a useful check: sometimes the point of selection changes, and sometimes the graph composition itself changes.
9. Typical mistakes when working with @Primary, @Qualifier, Optional and List
Mistake #1: putting @Qualifier "just in case" in every constructor.
This turns DI into manual "wires in the wall": any refactoring of the bean name or class reordering starts breaking the build. @Qualifier should appear where you are really making a conscious choice of a specific implementation. If no choice is needed, it is better to leave a clean type-based injection and solve ambiguity through @Primary or simply not create the second bean.
Mistake #2: using @Primary as a way to "patch" ambiguity without understanding who is the main one.
If you set @Primary just because Spring complains, you are basically hiding the decision in an annotation. A month later another developer adds a third implementation, and nobody will remember why exactly this one was "the main one". @Primary is good when it is truly a "default implementation", not a random winner.
Mistake #3: thinking that Optional<T> creates a bean automatically.
Optional only means "we allow absence". If there is no CatalogBannerWriter in the context, then Optional will be empty, and that is normal. But if you expect that "some default writer" will appear, then you need to create it explicitly (for example, as a separate no-op bean), and Optional will not help here.
Mistake #4: expecting List<T> to return "one element" or "some main one".
List<T> means "give me all beans of this type". This is not selection, this is collection. If you accidentally add another CourseCardRule, it will end up in the list, and the pipeline behavior will change. That is not a Spring bug — it is an honest contract. If you need one "main" bean, this is not List, but single-value injection plus @Primary/@Qualifier.
Mistake #5: relying on the order of elements in List<T> without setting it explicitly.
Today the rules were applied in A→B order, tomorrow in B→A order, and you start suspecting the moon phase and retrograde Mercury. In reality, the order may depend on how classes were discovered and registered. If order matters, use @Order and make it an explicit part of the design.
GO TO FULL VERSION