DEV Community

Ankit Verma
Ankit Verma

Posted on

@Autowired resolution, @Qualifier/@Primary, injection types + circular-dependency gotcha

The last article left the container in a tidy state. Scanning has finished, and every class you marked is now a bean definition sitting in the container, names and all. But a definition is just a recipe. The moment the container starts actually building beans, it hits the question this article is about: to build OrderService, it needs to pass something into the PaymentGateway slot in its constructor — so which bean does it reach for?

When exactly one bean fits, the answer is obvious and you never think about it. The interesting part is everything around that happy path: how the container makes the match, what happens when two beans fit the same slot, and how you steer the choice when it can't decide on its own.

That whole job — the container finding and supplying the beans a dependency needs, instead of you naming each one by hand — is called autowiring. You meet it for real the first time Spring refuses to start, complaining it found more than one bean of some type and doesn't know which you meant. To fix that on purpose instead of by trial and error, you need to see how resolution actually works.

This article walks it end to end: the match by type, why @Autowired is often already implied, the ambiguity that breaks the match, the three tools that break the tie, what to do when a bean might be missing — and finally a deeper look at the circular-dependency trap the dependency-injection article only cracked open.

The happy path: resolution by type

Start with the rule from the dependency-injection article, now seen from the container's side. When the container builds a bean and reaches a dependency, it looks through every bean it knows about for one whose type fits, and injects that one.

@Service
class OrderService {
    private final PaymentGateway gateway;

    OrderService(PaymentGateway gateway) {   // "I need a PaymentGateway"
        this.gateway = gateway;
    }
}
Enter fullscreen mode Exit fullscreen mode

If there is exactly one PaymentGateway bean in the container, the match is unambiguous and the wiring just happens. You declared a need by type; the container found the one bean that satisfies it and handed it over. That is autowiring in its simplest, invisible form.

Where did @Autowired go?

You may have noticed there is no @Autowired on that constructor. That is not an oversight. When a class has a single constructor, Spring treats it as an injection point automatically — it assumes the parameters are dependencies to resolve, no annotation required.

So @Autowired only needs to appear in the cases Spring cannot infer:

  • On a class with more than one constructor, to mark which one the container should call.
  • On a field or setter, where there is no sole constructor for Spring to assume.
@Service
class OrderService {
    @Autowired                                  // needed here only if there are other constructors
    OrderService(PaymentGateway gateway) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

The useful way to hold it: @Autowired marks an injection point — a spot that says "fill this in from the container." A parameter on a sole constructor is an injection point implicitly. Everywhere else, the annotation is how you point at one.

When two beans fit one slot

Now the situation that breaks the happy path. Suppose two classes implement the same interface, and both are beans:

@Component
class StripeGateway implements PaymentGateway { /* ... */ }

@Component
class AdyenGateway implements PaymentGateway { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

OrderService still asks for a single PaymentGateway. The container searches by type and now finds two candidates. It has no basis to prefer one, and it will not guess. It fails at startup:

NoUniqueBeanDefinitionException: expected single matching bean
but found 2: stripeGateway, adyenGateway
Enter fullscreen mode Exit fullscreen mode

This is the fail-fast philosophy again — an ambiguous wiring is caught at boot, on your machine, not silently resolved into the wrong gateway in production. Spring gives you three ways to resolve it, and they are worth knowing as a set, because they layer.

Tie-breaker 1: the parameter name

Before you reach for any annotation, Spring has a quiet fallback already running. When the type matches several beans, it tries to narrow them by name — matching the name of the injection point against the bean names. Recall from the scanning article that a scanned bean is named after its class, decapitalized: StripeGateway becomes stripeGateway.

@Service
class OrderService {
    OrderService(PaymentGateway stripeGateway) {   // parameter name matches a bean name
        // resolves to the stripeGateway bean
    }
}
Enter fullscreen mode Exit fullscreen mode

Name the parameter stripeGateway and the ambiguity disappears: the container matches it to the bean of that name. It works, but lean on it carefully. It depends on parameter names surviving compilation (Spring Boot compiles with that turned on, so they do), and it is subtle — renaming a parameter silently rewires the application. It is fine as a fallback; it is a poor way to express intent. For that, be explicit.

Tie-breaker 2: @Primary — declare a default winner

@Primary marks one bean as the default to use whenever several fit:

@Component
@Primary
class StripeGateway implements PaymentGateway { /* ... */ }

@Component
class AdyenGateway implements PaymentGateway { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Now any unqualified PaymentGateway injection, anywhere in the app, gets Stripe. The key trait is that @Primary lives on the bean — one declaration, applied everywhere that type is needed. It is the right tool when there is one obvious default and only rare exceptions.

Tie-breaker 3: @Qualifier — name the one you want

@Primary decides globally. @Qualifier decides at a single injection point. You give the beans qualifier names and then ask for one by name right where you need it:

@Component
@Qualifier("stripe")
class StripeGateway implements PaymentGateway { /* ... */ }

@Component
@Qualifier("adyen")
class AdyenGateway implements PaymentGateway { /* ... */ }

@Service
class OrderService {
    OrderService(@Qualifier("stripe") PaymentGateway gateway) {   // this slot wants Stripe
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

And the two combine cleanly. When both are present, @Qualifier at the injection point wins over @Primary. The idiom that falls out: mark the everyday default with @Primary, and use @Qualifier only at the few spots that need the exception. Most of the code stays quiet; the deviations are explicit.

When the bean might not be there

The mirror image of ambiguity is absence. If a required dependency has no matching bean, the container fails at boot with NoSuchBeanDefinitionException — the same fail-fast instinct. Usually that is exactly what you want. But occasionally a dependency is genuinely optional: a metrics sink that may or may not be configured, say. Spring has three ways to say "inject it if it exists, otherwise carry on":

  • @Autowired(required = false) — leaves a field or setter unset if no bean is found.
  • Optional<PaymentGateway> — arrives empty when the bean is absent.
  • ObjectProvider<PaymentGateway> — a lazy handle you ask for the bean only when you need it (the same type the bean-scopes article used to pull a fresh prototype on demand).
@Service
class OrderService {
    private final Optional<PaymentGateway> gateway;

    OrderService(Optional<PaymentGateway> gateway) {   // empty if no gateway bean exists
        this.gateway = gateway;
    }
}
Enter fullscreen mode Exit fullscreen mode

Prefer Optional or ObjectProvider over required = false: they keep the field final and make the "might be absent" part visible in the type, instead of hiding it behind a field that is sometimes null. (And note the List<PaymentGateway> form from the DI article is a different case entirely — it asks for all matching beans on purpose, so several matches are the answer, not an error.)

Injection types, seen through resolution

The DI article already made the case for constructor injection over setters and fields, so this is just the same picture from the resolver's angle: the matching algorithm above is identical no matter where the injection point sits. @Qualifier and @Primary work the same on a constructor parameter, a setter, or a field.

What changes is when resolution runs and how safely. Constructor injection resolves everything at construction, so the object is never half-built and the fields can be final. Setter injection runs during the populate stage, which suits a truly optional or reconfigurable dependency. Field injection is the most concise and the least testable. The resolution rules don't pick a style for you — but as the next section shows, the style you pick decides how one particular failure behaves.

The circular-dependency gotcha, in full

The DI article showed one slice of this: two beans that depend on each other, wired through constructors, fail at boot. Here is the complete picture, because the outcome depends entirely on the injection style — and that is the whole lesson.

Say A needs B, and B needs A. With constructor injection on both sides, neither object can be built first: building A needs a finished B, which needs a finished A, and round it goes. The container detects the loop and stops:

BeanCurrentlyInCreationException:
Requested bean is currently in creation  is there an unresolvable circular reference?
Enter fullscreen mode Exit fullscreen mode

That looks like an obstacle. It is actually the good case — the cycle is exposed the instant you start the app.

Now switch the two beans to field injection and watch the failure quietly vanish:

@Component
class A {
    @Autowired B b;
}

@Component
class B {
    @Autowired A a;
}
Enter fullscreen mode Exit fullscreen mode

Because field injection happens after construction, Spring has a move available that constructors deny it. It builds the raw A first, then — before A is fully wired — exposes a reference to that half-built A. It builds B, setting B's field to the early A reference. With B now complete, it finishes wiring A by setting A's field to the finished B. The cycle resolves, and the application starts as if nothing were wrong.

That "as if nothing were wrong" is the trap. A circular dependency is a real design flaw — two beans so entangled that neither is whole without the other — and field injection lets it ship undetected. Constructor injection would have failed loudly at boot and forced the conversation. This is one more reason the earlier articles pushed constructors so hard: the strictness is the safety.

Spring offers an escape hatch, @Lazy, for when you must break a cycle without redesigning right now. Put @Lazy on one of the two injection points and the container injects a proxy — the same stand-in trick from the first article — instead of the real bean, deferring the real lookup until the first method call. By then both beans exist, so the loop never has to be resolved at construction time.

@Component
class A {
    A(@Lazy B b) { /* ... */ }   // injects a proxy now, resolves B on first use
}
Enter fullscreen mode Exit fullscreen mode

Treat that as a patch, not a cure. The honest fix is to break the cycle: pull the shared logic into a third bean both can depend on, or let one side publish an event the other listens for, the way the ApplicationContext article wired OrderService to email without either knowing the other. A mutual dependency almost always means a responsibility is living in the wrong class.

It is worth knowing that recent Spring Boot takes your side here: since 2.6 it forbids circular references by default, so even a field-injected cycle fails at startup unless you deliberately set spring.main.allow-circular-references=true. The framework now nudges everyone toward the fix instead of the papering-over.

Putting it together

Autowiring is the container resolving each dependency by type: one matching bean and the wiring is silent, with @Autowired implied on a sole constructor and only spelled out for extra constructors, fields, and setters. When two beans fit one slot, the container fails fast rather than guess — and you break the tie three ways: the parameter name (a subtle fallback), @Primary (one global default on the bean), or @Qualifier (a named choice at the injection point that overrides @Primary). When a bean might be missing, Optional or ObjectProvider make the absence explicit instead of crashing.

The deepest point is the one about cycles. Resolution itself doesn't care which injection style you used — but a circular dependency does. Constructor injection turns it into a boot-time error you cannot miss; field injection papers over it and lets a design flaw ship. That asymmetry, now reinforced by Boot's default of refusing cycles outright, is the last and strongest argument for wiring through the constructor.

With that, the container's whole story is on the table: how it learns what your beans are, builds them, scopes them, and now connects them to each other. The next article steps back from the individual mechanisms and ties the entire Foundations module into one mental model — a recap of how every piece we have built fits together.

Top comments (0)