Spring can hand a dependency to your object in three places: a constructor parameter, a setter method, or straight into a private field. All three work. They differ in exactly one thing — the moment the value arrives relative to the object's own construction — and every other difference between them, including which ones let you write a plain unit test and which ones let a circular dependency through, follows from that single fact.
Everything below was run on Spring Boot 4.1.1 (Spring Framework 7.0.9) with Java 21.0.6 and Gradle 9.7.1. Every console line, exception and failure banner is copied out of the terminal, including the ones that were reproduced on purpose.
![]()
The example is deliberately small: an AuditLog component and three services that each want one, written the three different ways.
Constructor, setter and field: the three injection points
Here are the three, side by side. The dependency is the same in all three cases; only the way in changes.
@Service
public class ByConstructor {
private final AuditLog auditLog;
public ByConstructor(AuditLog auditLog) {
this.auditLog = auditLog;
}
public void use() { auditLog.record("constructor injection"); }
}@Service
public class BySetter {
private AuditLog auditLog;
@Autowired
public void setAuditLog(AuditLog auditLog) {
this.auditLog = auditLog;
}
public void use() { auditLog.record("setter injection"); }
}@Service
public class ByField {
@Autowired
private AuditLog auditLog;
public void use() { auditLog.record("field injection"); }
}Three things are already visible without running anything. Only the first can declare auditLog as final. Only the first has no @Autowired anywhere. And only the first would still compile if you deleted Spring from the classpath and kept the class.
The trace: when each one gets its value
Add a print to each constructor and to the setter, and the container tells you the rest. This is the real output:
[constructor] inside the constructor, auditLog = injected
[field] inside the constructor, auditLog = null
[setter] inside the constructor, auditLog = null
[setter] inside setAuditLog(), auditLog = injected
---- context is up, every bean is complete ----
AUDIT: constructor injection
AUDIT: setter injection
AUDIT: field injectionRead the first four lines carefully, because they are the whole article in miniature:
- Constructor injection. The container resolves
AuditLogfirst, then callsnew ByConstructor(auditLog). The value is present from the first statement of the constructor body. There is no instant at which aByConstructorexists without its dependency. - Setter injection. The container calls the no-argument constructor, gets back an object whose field is
null, and only then callssetAuditLog(...). Between those two calls a fully-typedBySetterexists with a null field. - Field injection. Identical timing to setter injection, but the write happens by reflection directly into the private field instead of through a method. The reflective write is why no setter is needed and why the field cannot be
final.

By the time the context has started — the ---- context is up ---- line — all three are complete and all three work. The difference only exists during startup. It just happens that a great deal of what goes wrong with dependency injection goes wrong during startup.
Why is @Autowired optional on a constructor?
ByConstructor above has no annotation on its constructor and is still injected. Since Spring 4.3, a class with exactly one constructor does not need @Autowired on it — the container has no choice to make, so it uses that constructor and resolves every parameter as a dependency. This is why modern Spring code has almost no @Autowired in it at all.
"Exactly one" is the entire rule. Add a second constructor and the container is suddenly being asked to choose, and it refuses to guess.
Two constructors, and the silence that follows
@Service
public class NotificationService {
private final AuditLog auditLog;
private final Mailer mailer;
public NotificationService(AuditLog auditLog) {
this(auditLog, null);
}
public NotificationService(AuditLog auditLog, Mailer mailer) {
this.auditLog = auditLog;
this.mailer = mailer;
}
public void notifyUser(String to) { /* ... */ }
}Two constructors, neither annotated. The application does not start:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name
'notificationService' defined in file [.../com/example/demo/NotificationService.class]:
Failed to instantiate [com.example.demo.NotificationService]: No default constructor found
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate
[com.example.demo.NotificationService]: No default constructor found
Caused by: java.lang.NoSuchMethodException: com.example.demo.NotificationService.<init>()That message misleads almost everyone who meets it. It is not saying "add a no-argument constructor". It is saying: I found more than one candidate constructor, none of them was marked, so I fell back to looking for a default constructor and there isn't one either. The fix is to mark the one you want:
@Autowired
public NotificationService(AuditLog auditLog, Mailer mailer) {
this.auditLog = auditLog;
this.mailer = mailer;
}AUDIT: notify ada@example.com
MAIL -> ada@example.comThere is a nastier version of the same situation. If one of the two constructors is a no-argument constructor, the fallback succeeds — and nothing complains at all:
public NotificationService() {
System.out.println("chosen constructor: NotificationService()");
}
public NotificationService(AuditLog auditLog, Mailer mailer) {
System.out.println("chosen constructor: NotificationService(AuditLog, Mailer)");
this.auditLog = auditLog;
this.mailer = mailer;
}chosen constructor: NotificationService()
Exception in thread "main" java.lang.NullPointerException: Cannot invoke
"com.example.demo.AuditLog.record(String)" because "this.auditLog" is null
at com.example.demo.NotificationService.notifyUser(NotificationService.java:22)The context started cleanly. The bean exists. Every field in it is null, and you find out at the first request. One constructor needs no annotation; two constructors need exactly one @Autowired.
The case for constructor injection
The Spring reference documentation recommends constructor injection, and the recommendation is easy to repeat and easy to under-explain. Here are the four reasons, each demonstrated rather than asserted.
final fields and an object that is complete the moment it exists
Constructor injection lets the field be final. That is not decoration: it means the field is assigned exactly once, during construction, and the compiler enforces it. Nobody can null it out later, no @PostConstruct can be forgotten, and any thread that gets a reference to the object is guaranteed to see the fully initialised field.
Field injection cannot do this, and the compiler says so before Spring is ever involved:
@Service
public class FinalFieldService {
@Autowired
private final AuditLog auditLog;
public void use() { auditLog.record("final field"); }
}FinalFieldService.java:10: error: variable auditLog not initialized in the default constructorThere is no trick that recovers this. A final field must be assigned by the end of every constructor, and the whole point of field injection is that the write happens after the constructor has already returned. final and field injection are mutually exclusive.
The null window that field injection leaves open
"The field is null for a moment" sounds theoretical until you write something in the constructor. This class is not contrived — precomputing a value in the constructor is ordinary Java:
@Service
public class ReportService {
@Autowired
private AuditLog auditLog;
private final String banner;
public ReportService() {
auditLog.record("ReportService starting up");
this.banner = "reports ready";
}
}org.springframework.beans.factory.BeanCreationException: Error creating bean with name
'reportService' defined in file [.../com/example/demo/ReportService.class]:
Failed to instantiate [com.example.demo.ReportService]: Constructor threw exception
Caused by: java.lang.NullPointerException: Cannot invoke
"com.example.demo.AuditLog.record(String)" because "this.auditLog" is null
at com.example.demo.ReportService.<init>(ReportService.java:15) ~[main/:na]The dependency is declared, the bean it needs exists in the context, and it is still null — because the container has not got to the reflective write yet. Nothing about the code reads as wrong; the ordering is invisible.
Now the same mistake with constructor injection. There is no mistake to make: the parameter is in scope, non-null, before the first statement of the body runs. And when the dependency genuinely is missing, the failure is not an NPE somewhere inside your code — it is a diagnosis, printed before the application starts:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.CartService required a bean of type
'com.example.demo.PricingClient' that could not be found.
Action:
Consider defining a bean of type 'com.example.demo.PricingClient' in your configuration.To be fair to field injection: a missing bean fails at startup there too, and the message is just as good, naming the field and the annotation on it.
Description:
Field pricing in com.example.demo.CartService required a bean of type
'com.example.demo.PricingClient' that could not be found.
The injection point has the following annotations:
- @org.springframework.beans.factory.annotation.Autowired(required=true)The difference is not "startup versus runtime" for a missing bean. It is that constructor injection makes the half-built object unrepresentable, so the NPE window above simply does not exist.
Dependencies you can count in the signature
A class with eight dependencies is a class doing eight things. With constructor injection it looks like what it is:
public OrderService(OrderRepository orders, PaymentGateway payments, InventoryClient inventory,
PricingService pricing, EmailSender email, AuditLog audit,
MetricsRecorder metrics, FeatureFlags flags) {Nobody reads that and thinks it is fine. With field injection, the same eight dependencies are eight one-line annotations spread down the class body, and each individual line looks harmless — which is precisely the problem. The pain of a long parameter list is useful pain: it is the design feedback that tells you the class wants splitting. Field injection anaesthetises it.
A unit test with no Spring context in it
This is the argument that settles it in practice. A constructor-injected class is an ordinary Java class, so a test can build it directly with a stub and never mention Spring:
class CheckoutServiceTest {
private final PaymentGateway stub = amountCents -> "stub:" + amountCents;
@Test
void constructor_injected_service_needs_no_container() {
CheckoutService service = new CheckoutService(stub);
assertThat(service.checkout(2500)).isEqualTo("stub:2500");
}
@Test
void field_injected_service_cannot_be_built_the_same_way() {
FieldCheckoutService service = new FieldCheckoutService();
assertThatThrownBy(() -> service.checkout(2500))
.isInstanceOf(NullPointerException.class)
.hasMessage("Cannot invoke \"com.example.demo.PaymentGateway.charge(long)\" "
+ "because \"this.gateway\" is null");
}
@Test
void field_injected_service_needs_reflection_to_test() {
FieldCheckoutService service = new FieldCheckoutService();
ReflectionTestUtils.setField(service, "gateway", stub);
assertThat(service.checkout(2500)).isEqualTo("stub:2500");
}
}All three pass:
CheckoutServiceTest > field_injected_service_needs_reflection_to_test() PASSED
CheckoutServiceTest > field_injected_service_cannot_be_built_the_same_way() PASSED
CheckoutServiceTest > constructor_injected_service_needs_no_container() PASSED
BUILD SUCCESSFUL in 703ms
4 actionable tasks: 4 executedThe middle test is the one to look at. new FieldCheckoutService() compiles, runs, and hands you an object that throws the moment you use it. The third test shows the only way out without a container: ReflectionTestUtils.setField(service, "gateway", stub) — which means your test now depends on the name of a private field, and a rename that the compiler would normally catch silently breaks it instead.
The alternative to reflection is to start a context, and that is not free. The same single assertion, measured from the JUnit XML report over three runs on this machine:
| Test style | Suite time |
|---|---|
| Plain constructor call, no Spring | 0.021 s, 0.021 s, 0.027 s |
@SpringBootTest with the context started | 0.787 s, 0.849 s, 0.913 s |
Roughly forty times, on an application with one bean of interest. Treat the numbers as indicative of the shape rather than as a benchmark — but the shape does not change as the application grows, it gets worse. Constructor injection is what keeps the fast path available.
When is setter injection the right choice?
Setter injection is not a mistake; it is a narrower tool. It earns its place in two situations.
The first is a genuinely optional dependency: something the class works without, where the absence is a supported configuration rather than a failure. The second is a collaborator that can legitimately be replaced after construction — a strategy swapped at runtime, a component reconfigured by an administrative endpoint. Both of those need a mutable field by definition, so final was never on the table anyway.
What setter injection is not for is "this class has too many constructor parameters". That is the design feedback from the previous section; muting it with setters does not make the class smaller.
Optional dependencies: required = false, Optional and ObjectProvider
There are four ways to say "inject this if it exists". Here they are in one class, with no FraudCheck bean registered anywhere:
@Service
public class OptionalDemo {
private final Optional<FraudCheck> viaOptional;
private final ObjectProvider<FraudCheck> viaProvider;
private final @Nullable FraudCheck viaNullable;
@Autowired(required = false)
private FraudCheck viaField;
private FraudCheck viaSetter;
public OptionalDemo(Optional<FraudCheck> viaOptional,
ObjectProvider<FraudCheck> viaProvider,
@Nullable FraudCheck viaNullable) {
this.viaOptional = viaOptional;
this.viaProvider = viaProvider;
this.viaNullable = viaNullable;
}
@Autowired(required = false)
public void setViaSetter(FraudCheck viaSetter) {
System.out.println("setViaSetter() was called");
this.viaSetter = viaSetter;
}
}Optional<FraudCheck> : isPresent=false
ObjectProvider<FraudCheck> : getIfAvailable=null
@Nullable FraudCheck : null
@Autowired(required=false) field : null
@Autowired(required=false) setter : nullNote what is missing from that output: setViaSetter() was called never printed. With required = false on a setter, the container does not call the method at all when no candidate exists. That is a real behavioural difference from the field form, and it is the reason a required = false setter must never be the only place a default is applied — the method may simply never run.
Register one implementation and everything fills in, the setter included:
setViaSetter() was called
Optional<FraudCheck> : isPresent=true
ObjectProvider<FraudCheck> : getIfAvailable=VelocityFraudCheck
@Nullable FraudCheck : VelocityFraudCheck
@Autowired(required=false) field : VelocityFraudCheck
@Autowired(required=false) setter : VelocityFraudCheckWhich to prefer:
| Way to say "optional" | Use it when |
|---|---|
Optional<T> as a constructor parameter | The default choice. The type itself documents the optionality, the field can stay final, and the compiler forces every caller to handle the empty case. |
@Nullable T as a constructor parameter | You want a plain T field rather than an Optional one. Spring Framework 7 uses JSpecify's org.jspecify.annotations.Nullable, which is on the classpath already. |
ObjectProvider<T> | You need more than presence: getIfAvailable(), getIfUnique(), stream() and orderedStream() all live here, and resolution is deferred until you ask. |
@Autowired(required = false) | Legacy code and nothing else. On a field it leaves you a bare null; on a setter the method may never be called. |
Two beans of the same type: NoUniqueBeanDefinitionException
Everything so far assumed one candidate per type. The interesting half of dependency injection starts when there are two. One interface, two implementations, both components:
public interface PaymentGateway {
String charge(long amountCents);
}
@Component
public class StripeGateway implements PaymentGateway {
@Override
public String charge(long amountCents) { return "stripe:" + amountCents; }
}
@Component
public class PaypalGateway implements PaymentGateway {
@Override
public String charge(long amountCents) { return "paypal:" + amountCents; }
}
@Service
public class CheckoutService {
private final PaymentGateway gateway;
public CheckoutService(PaymentGateway gateway) {
this.gateway = gateway;
}
public String checkout(long amountCents) { return gateway.charge(amountCents); }
}The application does not start. The underlying exception is the one whose name everybody eventually learns:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with
name 'checkoutService' defined in file [.../com/example/demo/CheckoutService.class]:
Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type
'com.example.demo.PaymentGateway' available: expected single matching bean but found 2:
paypalGateway,stripeGatewayAnd Spring Boot's failure analyser turns it into this, which is what you will actually see on the console:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.CheckoutService required a single bean, but 2 were found:
- paypalGateway: defined in file [.../com/example/demo/PaypalGateway.class]
- stripeGateway: defined in file [.../com/example/demo/StripeGateway.class]
This may be due to missing parameter name information
Action:
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed
Ensure that your compiler is configured to use the '-parameters' flag.
You may need to update both your build tool settings as well as your IDE.
(See https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-6.1-Release-Notes#parameter-name-retention)The -parameters hint at the bottom is printed unconditionally by the analyser and is usually a red herring on a Gradle or Maven Spring Boot build, where the plugin already turns that flag on. It matters for exactly one of the four fixes below, and the section on that fix proves both halves.
The resolution algorithm is short and worth memorising: match by type, and if more than one candidate survives, break the tie by @Primary, then by jakarta.annotation.Priority, then by matching the bean name against the injection point's name. If nothing breaks the tie, refuse to start.

Fix 1: mark one bean @Primary
@Component
@Primary
public class StripeGateway implements PaymentGateway { /* ... */ }checkout -> stripe:2500@Primary sits on the bean, not on the injection point, and answers a question about the whole application: "when nobody says otherwise, which one?" That makes it right for a genuine default — the main DataSource, the ordinary ObjectMapper, the gateway ninety per cent of the code should use. It is wrong as a way to silence one error message, because it silently answers for every other injection point too, including ones written next year.
Two beans both marked @Primary is back to square one, with a more specific message:
NoUniqueBeanDefinitionException: No qualifying bean of type 'com.example.demo.PaymentGateway'
available: more than one 'primary' bean found among candidates: [paypalGateway, stripeGateway]Fix 2: @Qualifier at the injection point
public CheckoutService(@Qualifier("paypalGateway") PaymentGateway gateway) {
this.gateway = gateway;
}checkout -> paypal:2500@Qualifier is the opposite of @Primary: it lives at the injection point and decides one wiring only. The string matches a qualifier value declared on a bean, and — as here — falls back to matching the bean name. Both are worth knowing, because naming the bean is what most examples do and declaring a qualifier is what survives a class rename:
@Component
@Qualifier("cards")
public class StripeGateway implements PaymentGateway { /* ... */ } public CheckoutService(@Qualifier("cards") PaymentGateway gateway) { /* ... */ }checkout -> stripe:2500This form is the better one. @Qualifier("stripeGateway") couples the caller to the implementation's class name; @Qualifier("cards") couples it to a role, which is the thing the caller actually cares about.
@Qualifier works at every injection point, not just constructors — on a field, and on a setter's parameter:
@Autowired
@Qualifier("stripeGateway")
private PaymentGateway gateway;
@Autowired
public void setOther(@Qualifier("paypalGateway") PaymentGateway other) {
this.other = other;
}checkout -> stripe:2500 / paypal:2500Fix 3: match the parameter name to the bean name
The last step of the resolution algorithm is a name match, and you can aim it deliberately. Rename nothing but the parameter:
public CheckoutService(PaymentGateway stripeGateway) {
this.gateway = stripeGateway;
}checkout -> stripe:2500No annotation at all, and it resolves. This is also why @Autowired private PaymentGateway stripeGateway; "just works" in so many tutorials — the field name is doing the qualifying.
Now the caveat, which is what that -parameters hint in the failure banner was about. Parameter names only survive into the class file when javac is given -parameters; without it, the constructor's parameter is called arg0 at runtime and there is nothing to match. The Spring Boot Gradle and Maven plugins add the flag for you — javap -v on the class above shows the attribute:
MethodParameters:
Name Flags
stripeGatewayTake the flag away, change nothing else, and the MethodParameters attribute disappears along with the wiring:
tasks.withType(JavaCompile).configureEach {
options.compilerArgs.remove('-parameters')
}Description:
Parameter 0 of constructor in com.example.demo.CheckoutService required a single bean, but 2 were found:
- paypalGateway: ...
- stripeGateway: ...So the mechanism is real but fragile: it depends on a compiler flag, it breaks under any obfuscator, and a rename that an IDE treats as safe — renaming a parameter — silently changes which bean is wired. Use it knowingly, or not at all.
Fix 4: a custom qualifier annotation
@Qualifier is a meta-annotation, so you can build a typed qualifier of your own out of it:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER })
public @interface CardPayments {
}@Component
@CardPayments
public class StripeGateway implements PaymentGateway { /* ... */ }
@Component
@WalletPayments
public class PaypalGateway implements PaymentGateway { /* ... */ }@Service
public class CheckoutService {
private final PaymentGateway cards;
private final PaymentGateway wallets;
public CheckoutService(@CardPayments PaymentGateway cards,
@WalletPayments PaymentGateway wallets) {
this.cards = cards;
this.wallets = wallets;
}
}checkout -> stripe:2500 / paypal:2500This is @Qualifier("cards") with the magic string replaced by a type. A typo is now a compile error instead of a startup failure, the IDE can find every usage, and the annotation has a Javadoc page where the meaning of "card payments" can be written down. On anything larger than a toy, this is the version to reach for.
@Primary and @Qualifier together
They can coexist, and the precedence is worth seeing rather than assuming. StripeGateway is @Primary; one of the two parameters below asks for something else:
public CheckoutService(PaymentGateway byDefault,
@Qualifier("paypalGateway") PaymentGateway byQualifier) {
this.byDefault = byDefault;
this.byQualifier = byQualifier;
}no qualifier -> stripe:2500
@Qualifier -> paypal:2500@Qualifier wins. @Primary is only consulted when the injection point expressed no preference, which is exactly the behaviour you want: a sensible default for everyone, overridable at the one place that needs the other thing.
Injecting every implementation at once
Sometimes the answer to "which implementation?" is "all of them" — a set of validators, a chain of handlers, a registry keyed by name. Declare a collection of the interface type and the container fills it:
@Service
public class GatewayRegistry {
private final List<PaymentGateway> all;
private final Map<String, PaymentGateway> byName;
public GatewayRegistry(List<PaymentGateway> all, Map<String, PaymentGateway> byName) {
this.all = all;
this.byName = byName;
}
}With three implementations on the classpath:
List<PaymentGateway> size = 3
BankTransferGateway -> bank:100
PaypalGateway -> paypal:100
StripeGateway -> stripe:100
Map<String, PaymentGateway> keys = [bankTransferGateway, paypalGateway, stripeGateway]
bankTransferGateway -> bank:100
paypalGateway -> paypal:100
stripeGateway -> stripe:100A List<T> gets every bean assignable to T. A Map<String, T> gets the same beans keyed by bean name — note that the key type must be String, because bean names are strings. This is the idiomatic way to let a feature be extended by adding a class: the registry does not change, the new @Component joins the collection automatically.
Ordering a List with @Order
The order in that list was not chosen by you. It happens to be scan order here, and scan order is not a contract — it can change when you rename a class, split a package, or repackage into a jar. If order matters, say so:
@Component
@Order(1)
public class StripeGateway implements PaymentGateway { /* ... */ }
@Component
@Order(2)
public class PaypalGateway implements PaymentGateway { /* ... */ }
@Component
@Order(3)
public class BankTransferGateway implements PaymentGateway { /* ... */ }List<PaymentGateway> size = 3
StripeGateway -> stripe:100
PaypalGateway -> paypal:100
BankTransferGateway -> bank:100
Map<String, PaymentGateway> keys = [bankTransferGateway, paypalGateway, stripeGateway]Two details in that output. The List is now in the declared order — lower numbers first — and a class can implement org.springframework.core.Ordered instead of using the annotation if the value has to be computed. And the Map is unchanged: sorting is applied to arrays and collections, not to maps, so a Map<String, T> gives you registration order and you should treat it as unordered.
ObjectProvider exposes the same choice explicitly, which is the clearest way to show what is going on:
public GatewayRegistry(ObjectProvider<PaymentGateway> gateways) {
this.gateways = gateways;
}stream() : [BankTransferGateway, PaypalGateway, StripeGateway]
orderedStream() : [StripeGateway, PaypalGateway, BankTransferGateway]
getIfUnique() : nullstream() gives you registration order, orderedStream() applies @Order, and getIfUnique() returns null rather than throwing when the type is ambiguous — a fifth, programmatic way of dealing with multiple candidates.
Circular dependencies
Two beans that each need the other is the one failure mode where the choice of injection point changes not just the error message but whether the application starts at all.
@Service
public class OrderService {
private final InvoiceService invoiceService;
public OrderService(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
}@Service
public class InvoiceService {
private final OrderService orderService;
public InvoiceService(OrderService orderService) {
this.orderService = orderService;
}
}Spring Boot draws the cycle for you:
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| invoiceService defined in file [.../com/example/demo/InvoiceService.class]
↑ ↓
| orderService defined in file [.../com/example/demo/OrderService.class]
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.This is not a policy decision by Spring, it is arithmetic. To construct orderService the container needs a finished invoiceService; to construct invoiceService it needs a finished orderService. Neither can be first. There is nothing a configuration property could do about it, and the property named in that Action: block proves it — turn it on and the very same cycle fails again, with a different last line:
spring.main.allow-circular-references=trueDescription:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| invoiceService defined in file [.../com/example/demo/InvoiceService.class]
↑ ↓
| orderService defined in file [.../com/example/demo/OrderService.class]
└─────┘
Action:
Despite circular references being allowed, the dependency cycle between beans could not be broken. Update your application to remove the dependency cycle.Now rewrite exactly the same two classes with field injection:
@Service
public class OrderService {
@Autowired
private InvoiceService invoiceService;
}@Service
public class InvoiceService {
@Autowired
private OrderService orderService;
}The cycle is now physically resolvable, because both objects can be created empty and the fields written afterwards — an early reference to the half-built orderService can be handed to invoiceService and patched up later. Spring Boot still refuses it by default (this has been the default since Boot 2.6), with the same banner naming the two fields instead of the two classes:
┌─────┐
| invoiceService (field private com.example.demo.OrderService com.example.demo.InvoiceService.orderService)
↑ ↓
| orderService (field private com.example.demo.InvoiceService com.example.demo.OrderService.invoiceService)
└─────┘But here the property really does work:
spring.main.allow-circular-references=trueorder:sku-1 invoice:sku-1
⚠️ That property is a migration aid, not a design option. It applies globally, it makes every future cycle in the application silent, and it leaves you with beans that hold references to objects that were incompletely initialised when the reference was taken. If you add it, add a dated comment next to it saying which cycle it is for.
What actually fixes a cycle
A cycle is information: it says two classes each know something the other needs, which means the boundary between them is in the wrong place. The real fixes, roughly in order of how often they are the right one:
- Move the shared logic into a third class that both depend on. The cycle usually exists because a piece of behaviour was assigned to one of the two arbitrarily.
- Invert one direction with an event. If
OrderServiceonly callsInvoiceServiceto tell it something happened, publish an application event instead of holding a reference. - Extract an interface that one side depends on and the other implements, so the compile-time dependency points one way even though the call goes the other.
- Merge them. Two classes that cannot be constructed independently are frequently one class that was split too early.
@Lazy on one of the two injection points will also break a constructor cycle, by injecting a proxy that resolves the real bean on first use — and the proxy is not hypothetical, it has a class name:
public OrderService(@Lazy InvoiceService invoiceService) {OrderService ctor, invoiceService is a InvoiceService$$SpringCGLIB$$0
order:sku-1 invoice:sku-1It works, and it is still the same cycle with a CGLIB proxy standing in front of it. Reach for it only when the two classes are not yours to change.
And note what constructor injection did for you here: it turned a design problem into a startup failure with both class names printed, on the first run, on your machine. Field injection would have taken the same design and started up fine.
Constructor vs setter vs field
| Constructor | Setter | Field | |
|---|---|---|---|
@Autowired needed | No, with exactly one constructor | Yes, on the method | Yes, on the field |
| When the value arrives | Before the constructor body runs | After construction, via the method | After construction, via reflection |
Field can be final | Yes | No | No |
| Object ever observable half-built | Never | Yes, between construction and the setter | Yes, between construction and the write |
| Visible in the public API | Yes — the constructor signature | Yes — the setter | No |
| Too many dependencies | Obvious, a long parameter list | Hidden | Hidden |
| Plain JUnit test without Spring | Yes, new it with stubs | Yes, new then call the setters | Only with ReflectionTestUtils or a container |
| Optional dependency | Optional<T>, @Nullable, ObjectProvider<T> | @Autowired(required = false) — method may never be called | @Autowired(required = false) — leaves null |
| Circular dependency | Fails at startup, always, property or not | Allowed if the property is set | Allowed if the property is set |
| Use it for | Everything mandatory. The default. | Genuinely optional or replaceable collaborators | Nothing in production code |
FAQ
Should I use Lombok's @RequiredArgsConstructor?
Yes, and it is genuinely constructor injection: Lombok generates a constructor taking every final field, and with a single constructor Spring needs no @Autowired. javap on the generated class confirms there is nothing special about it:
public class com.example.demo.CheckoutService {
private final com.example.demo.PaymentGateway gateway;
public com.example.demo.CheckoutService(com.example.demo.PaymentGateway);
}One trap is worth knowing. Annotations on the field are not copied to the generated constructor parameter by default, so a @Qualifier on a final field is ignored and you get the ambiguity failure back:
Parameter 0 of constructor in com.example.demo.CheckoutService required a single bean, but 2 were found:The fix is a lombok.config file at the project root, after which the qualifier is copied and the wiring resolves:
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.QualifierCan @Qualifier go on a setter or a field too?
Yes. On a field, put @Autowired and @Qualifier together on the field. On a setter, @Autowired goes on the method and @Qualifier on the parameter — that is the only form that works when the setter takes more than one argument, and it is the form to use habitually.
What happens if two beans are both marked @Primary?
The ambiguity comes straight back, with a message that names the specific problem — and note that Spring Boot prints no formatted failure banner for this one, just the exception:
NoUniqueBeanDefinitionException: No qualifying bean of type 'com.example.demo.PaymentGateway'
available: more than one 'primary' bean found among candidates: [paypalGateway, stripeGateway]@Primary means "the default", and there can only be one default. If two libraries each mark their own bean primary, the fix is a @Qualifier at your injection points, not a third @Primary.
Does @Autowired do anything on a static field?
No. The field stays null and the container logs one line at INFO that is very easy to scroll past:
INFO f.a.AutowiredAnnotationBeanPostProcessor : Autowired annotation is not supported on
static fields: private static com.example.demo.AuditLog com.example.demo.ByConstructor.auditLogA static field belongs to the class, not to a bean, so there is no instance for the container to populate. If you are reaching for a static field to get at a bean from somewhere that is not managed, the real fix is to make that code a bean too.
Is field injection ever acceptable?
In test classes, yes — @Autowired on a field in a @SpringBootTest is conventional, the object is built by the framework anyway, and none of the arguments above apply. In @Configuration classes it is at least defensible. In production service, repository and controller classes, there is no case for it that constructor injection does not serve better, and the final field alone is worth the two extra lines.
Why does my class have eight constructor parameters?
Because it has eight dependencies, and the constructor is the only place that shows it. The parameter list is a symptom, not the disease — switching to field injection hides the symptom and leaves the class exactly as complicated. Look for a cluster of parameters that are always used together and extract them into one collaborator, or split the class along the seam where different parameters are used by different methods.
Conclusion
There are three injection points and one thing that separates them: constructor injection supplies the value before the object exists, setter and field injection supply it afterwards. From that one fact follow final fields, an object that can never be observed half-built, a dependency count you cannot hide, a JUnit test that runs in 21 milliseconds because it never starts a context, and a circular dependency that fails loudly on the first run instead of lurking. @Autowired is optional on a single constructor and mandatory the moment there are two. When one type has several beans, resolution goes type, then @Primary, then @Priority, then name — and @Qualifier at the injection point overrules all of it, with a custom qualifier annotation being the version that survives a refactor. Want them all? Ask for a List or a Map, and add @Order if the order is part of the contract.
The next article moves from the injection point to the declaration: @Configuration and @Bean — factory methods that register beans the container could never have scanned for, and when to use them instead of a stereotype annotation.