NullPointerException: setter.place("A-2", 500). The service had one of its two collaborators wired and the other one forgotten, and it took the money before it discovered the gap. Nothing about that class looks wrong inside a Spring application, where the container calls every setter for you. It only fails when something else builds it — a unit test, a second configuration, a colleague who reads new SetterOrderService() and reasonably assumes the object is ready to use.
This article builds the same small order service three times — with constructor, setter and field injection — and then does to each version the things that actually go wrong: builds it without Spring, uses its dependency too early, gives it two constructors, and closes a circular loop through it. Every code block links to a file in the core-di module of a companion repository that compiles and runs, and every console block is quoted from a transcript that test run wrote, not typed in by hand.
Versions. Spring Boot 4.1.1 and Spring Framework 7.0.9 (both poms were published to Maven Central on 20 August 2026), on Java 25 (LTS, Temurin 25.0.4.1). Most demonstrations use a plain Spring ApplicationContext so nothing but the container is involved; the circular-dependency section switches to a real Spring Boot application because Boot changes the default there.
Dependency injection means someone else calls your constructor
A class rarely works alone. An order service needs something to take the payment and something to tell the customer. There are two ways for it to get them: build them itself, withnew, or be handed them. Dependency injection is the second option, and the “someone” doing the handing is Spring’s container, the ApplicationContext. It reads your class, works out what it needs, builds those things first, and passes them in. What you gain is a class that no longer knows which gateway it is talking to, which is exactly what lets a test give it a fake one.
StripeGateway itself, so nothing outside the class can replace it. In the right half the container owns the choice and the service only sees the PaymentGateway interface. Everything else in this article is a variation on the right half: how the handed-over gateway reaches the service. There are exactly three answers — through the constructor, through a setter, or straight into a field — and they behave very differently once something goes wrong.
The collaborator both versions share is small enough to read in full (PaymentGateway.java):
public interface PaymentGateway {
String charge(String orderId, int cents);
/** Every charge that really happened, so a test can prove a half-built service still took money. */
java.util.List<String> charges();
}
Going deeper: what “the container” is in these demos
Most scenarios in the repository run on a plain AnnotationConfigApplicationContext, created by a four-line helper (Ctx.java), rather than a full Spring Boot application. Boot adds auto-configuration, property binding and a different default for circular references, and none of that is needed to show how injection itself works. Where Boot’s behaviour is the point — the circular-dependency section — the tests and the scripts start a real SpringApplication instead, and say so.
The container remembers who it wired into whom. 17-dependency-graph.txt prints that registry with getDependenciesForBean:
constructorOrderService depends on [paymentGateway, notifier]
setterOrderService depends on [paymentGateway, notifier]
fieldOrderService depends on [paymentGateway, notifier]
paymentGateway is used by [constructorOrderService, setterOrderService, fieldOrderService]
All three services depend on the same two beans, and paymentGateway knows it is used by all three. The registry is identical whichever style produced the wiring, which is the first hint that the styles differ in what they let you do, not in what Spring does.
Going deeper
- Reference: Spring Framework – Dependency Injection (constructor-based and setter-based sections)
- Related on this site: @ConfigurationProperties vs @Value in Spring Boot 4, which uses constructor binding for the same reasons
The same order service, written three ways
The service does two things in a deliberate order: charge the customer, then notify them. That order matters later, when a missing collaborator lets the first step happen and blocks the second. Here it is with constructor injection (ConstructorOrderService.java):public class ConstructorOrderService implements OrderService {
private final PaymentGateway gateway;
private final Notifier notifier;
public ConstructorOrderService(PaymentGateway gateway, Notifier notifier) {
this.gateway = gateway;
this.notifier = notifier;
}
@Override
public String place(String orderId, int cents) {
String receipt = gateway.charge(orderId, cents);
notifier.send(receipt);
return receipt;
}
}
With setter injection (SetterOrderService.java) the fields are ordinary, non-final fields and Spring calls the annotated setters after it has constructed the object:
public class SetterOrderService implements OrderService {
private PaymentGateway gateway;
private Notifier notifier;
@Autowired
public void setGateway(PaymentGateway gateway) {
this.gateway = gateway;
}
@Autowired
public void setNotifier(Notifier notifier) {
this.notifier = notifier;
}
@Override
public String place(String orderId, int cents) {
String receipt = gateway.charge(orderId, cents);
notifier.send(receipt);
return receipt;
}
}
And with field injection (FieldOrderService.java) there is no constructor and no setter at all — Spring writes straight into the private fields by reflection:
public class FieldOrderService implements OrderService {
@Autowired
private PaymentGateway gateway;
@Autowired
private Notifier notifier;
@Override
public String place(String orderId, int cents) {
String receipt = gateway.charge(orderId, cents);
notifier.send(receipt);
return receipt;
}
}
Field injection is the shortest to write and, for that reason, the one most tutorials show first. The differences that matter are not visible in the code above; they show up when you try to use each class, which is what the next sections do. The table below is the summary to keep in mind while reading them.
| Constructor | Setter | Field | |
|---|---|---|---|
| How dependencies arrive | arguments to the constructor | setter calls after construction | reflection writes into private fields |
Can the field be final? | yes | no | no |
| Can the object exist half-wired? | never | yes, until every setter has run | yes, and only reflection can complete it |
Plain new in a unit test | you must pass everything | you can forget one | there is no way to pass anything |
Does Spring need @Autowired? | not with a single constructor | on every setter | on every field |
ConstructorOrderService -> charged 700 cents for B-1
SetterOrderService -> charged 700 cents for B-1
FieldOrderService -> charged 700 cents for B-1
Going deeper: the one honest use of a setter — an optional dependency
Setter injection has a job that constructors do badly: a collaborator that is allowed to be absent. @Autowired(required = false) on a setter says “wire this if a bean exists, otherwise leave it alone” (OptionalAudit.java):
public class OptionalAudit {
private Notifier notifier;
@Autowired(required = false)
public void setNotifier(Notifier notifier) {
this.notifier = notifier;
}
public String status() {
return notifier == null ? "no notifier configured, auditing silently" : "notifier present";
}
}
Run against a context with and without a Notifier bean, it behaves as advertised (06-optional-setter.txt):
with a Notifier bean : notifier present
without a Notifier bean : no notifier configured, auditing silently
There are better tools for “may not exist” once you leave the setter — Optional, @Nullable and ObjectProvider each behave differently with zero and with two candidates, and that comparison is the subject of the companion article, @Autowired Explained.
Going deeper
- Source: BaseBeans.java registers the two shared collaborators every variant is wired to
- Source: InjectionStylesTest.java is the test that produces every transcript in this section and the next
Without Spring, only the constructor version cannot be misused
Take Spring away and build each service the way a unit test would. The constructor version needs both collaborators before the compiler will let you have an object at all (01-three-styles-under-plain-new.txt). The setter version lets you forget one, and the field version gives you nothing to call.new. The green one is complete by construction. The amber one is complete only if every caller remembers every setter, and the compiler cannot check that. The red one is empty and stays empty: its fields are private, there is no constructor argument, and the only way in is reflection.
Here is the setter case as code. Only setGateway is called (InjectionStylesTest.java):
var gateway2 = new LoggingPaymentGateway();
var setter = new SetterOrderService();
setter.setGateway(gateway2);
String setterResult = Ctx.attempt(() -> setter.place("A-2", 500));
And what actually came back from that run, together with the field-injected version:
--- setter injection, one setter forgotten ---
new SetterOrderService(); setter.setGateway(gateway); // setNotifier never called
NullPointerException: Cannot invoke "com.ankurm.coredi.injection.Notifier.send(String)" because "this.notifier" is null
charges=1 <-- the customer was charged before the failure
--- field injection ---
new FieldOrderService().place("A-3", 500)
NullPointerException: Cannot invoke "com.ankurm.coredi.injection.PaymentGateway.charge(String, int)" because "this.gateway" is null
after ReflectionTestUtils.setField(...) twice:
OK -> charged 500 cents for A-3
The fingerprint of a half-wired service.The last two lines of that transcript show the escape hatch for the field version:charges=1next to aNullPointerExceptionis the whole bug in one line: the gateway was wired, so the customer was charged; the notifier was not, so the failure came after the side effect. The message itself is useful — Java printsbecause "this.notifier" is null— but by then the money has moved. Constructor injection cannot produce this state, because the object never exists without both.
ReflectionTestUtils.setField, which works, and which is also the reason many field-injected classes are tested only through a full Spring context.
Going deeper: what reflection-based test setup costs
ReflectionTestUtils.setField(field, "gateway", gateway3) identifies the field by a string. Rename the field in the class and the test still compiles; it fails at runtime with a message about a missing field. The constructor version puts the same information in the type system, so a rename or a new dependency breaks the test at compile time, on the line that needs to change.
Finality tells the same story. Reflection over the three classes (02-fields-and-finality.txt) shows which injected fields can be final:
ConstructorOrderService gateway final=true
ConstructorOrderService notifier final=true
SetterOrderService gateway final=false
SetterOrderService notifier final=false
FieldOrderService gateway final=false
FieldOrderService notifier final=false
Only the constructor version has final=true. A final field cannot be reassigned after construction, so nothing — not a stray setter call, not another thread — can swap a dependency underneath you, and, provided this does not escape during construction, the Java memory model guarantees that other threads see the assigned value once the constructor returns. The other two versions give up both properties.
Going deeper
- Source: Ctx.java (the four-line helper that builds a plain context)
- Reference: Spring Framework – Setter-based Dependency Injection
Inside Spring all three styles work — in a fixed order
Give the three services to a real container and they behave identically (07-spring-wires-all-three.txt above): each is built, wired and callable. What differs is when each kind of dependency arrives. To see it, one deliberately awkward bean uses all three styles at once and logs what it can see at each step (AllThreeStyles.java):public class AllThreeStyles {
@Autowired
private Notifier fieldDependency;
private PaymentGateway setterDependency;
private final Notifier constructorDependency;
public AllThreeStyles(Notifier constructorDependency) {
this.constructorDependency = constructorDependency;
Trace.log("constructor : constructor arg=" + describe(constructorDependency)
+ ", field=" + describe(fieldDependency) + ", setter=" + describe(setterDependency));
}
@Autowired
public void setSetterDependency(PaymentGateway gateway) {
this.setterDependency = gateway;
Trace.log("setter : setter arg=" + describe(gateway) + ", field=" + describe(fieldDependency));
}
@PostConstruct
void afterInjection() {
Trace.log("@PostConstruct: constructor=" + describe(constructorDependency)
+ ", field=" + describe(fieldDependency) + ", setter=" + describe(setterDependency));
}
private static String describe(Object o) {
return o == null ? "null" : "set";
}
}
constructor : constructor arg=set, field=null, setter=null
setter : setter arg=set, field=set
@PostConstruct: constructor=set, field=set, setter=set
@PostConstruct run. The setter line in the transcript shows field=set, which is how you can tell fields go before setters. The practical rule falls out of the picture: a dependency you receive by constructor is usable inside the constructor, and the other two kinds are not.
Going deeper: who does the injecting
Field and setter injection are performed by a BeanPostProcessor called AutowiredAnnotationBeanPostProcessor, which scans each class for @Autowired members after the bean has been instantiated. Constructor injection is different in kind: it is part of instantiation, because the constructor cannot run until its arguments exist. That difference is the whole reason for the timing in the diagram, and it is also why the circular-dependency section below behaves the way it does.
The post-processor is one of several that run for every bean. Their names and order for a real Boot context are printed in the companion article on the bean lifecycle, along with what each callback in the sequence is for.
Going deeper
- Related on this site: Spring Bean Lifecycle in Boot 4 for the full callback order after injection
- Reference:
@PostConstructand@PreDestroy
The timing trap: using a field-injected dependency in the constructor
The order above has a consequence that surprises people the first time. A class that uses a field-injected dependency in its own constructor — to warm something up, to read a default — readsnull, because the field is filled in after the constructor returns. It looks perfectly reasonable in the editor (FieldTrapService.java):
public class FieldTrapService {
@Autowired
private PaymentGateway gateway;
private final String warmup;
public FieldTrapService() {
this.warmup = gateway.charge("warm-up", 0);
}
public String warmup() {
return warmup;
}
}
Registering it in a context fails at start-up, and 04-field-used-in-constructor.txt has the messages:
context started: false
top exception : org.springframework.beans.factory.BeanCreationException
top message : Error creating bean with name 'fieldTrapService': Failed to instantiate [com.ankurm.coredi.injection.FieldTrapService]: Constructor threw exception
root cause : java.lang.NullPointerException: Cannot invoke "com.ankurm.coredi.injection.PaymentGateway.charge(String, int)" because "this.gateway" is null
The fingerprint.Failed to instantiate ... Constructor threw exceptionwrapping aNullPointerExceptionthat saysbecause "this.gateway" is nullon a field you can plainly see is annotated@Autowired. The annotation is fine; the code ran too early. Move the work into a@PostConstructmethod, or better, make the dependency a constructor argument so the question cannot arise.
Going deeper: why the compiler is no help here
Nothing in Java stops a constructor from reading a field that is assigned later, and Spring cannot warn you at compile time because injection is a runtime, reflective act. The failure only appears when a context tries to build the bean, which is why it tends to surface as a broken application start rather than a red squiggle. A constructor argument, by contrast, is a local variable in the constructor: the compiler will not let you use it before it is in scope, and it is never null unless you pass null.
The same reasoning is why constructor-injected classes never need a null check on their required collaborators, while field- and setter-injected ones quietly rely on the container having done its job.
Going deeper
- Source: InjectionStylesTest.java (
fieldInjectedDependencyUsedInConstructor) - Related on this site: Spring AOP: why an aspect does not fire, another failure that only shows up at runtime
Two constructors: Spring’s silent fallback
With exactly one constructor, Spring uses it. With two, and no annotation, it cannot guess which you meant — so it does something quieter than you might expect. It looks for a no-argument constructor and uses that, wiring nothing. If there is none, it fails. Both behaviours are in 05-multiple-constructors.txt:--- TwoConstructorsService: no-arg + one-arg, neither annotated ---
[no-arg constructor used]
gateway injected: false
--- TwoConstructorsNoDefault: one-arg + two-arg, neither annotated ---
org.springframework.beans.factory.BeanCreationException
Error creating bean with name 'twoConstructorsNoDefault': Failed to instantiate [com.ankurm.coredi.injection.TwoConstructorsNoDefault]: No default constructor found
root cause: java.lang.NoSuchMethodException: com.ankurm.coredi.injection.TwoConstructorsNoDefault.<init>()
--- TwoConstructorsAnnotated: @Autowired on one constructor ---
[annotated constructor used]
The first block is the dangerous one. TwoConstructorsService has a no-argument constructor and a one-argument constructor that takes the gateway (TwoConstructorsService.java); Spring picked the first, the context started cleanly, and gateway injected: false is the only sign anything is wrong. The second block is the loud version, and the third shows the one-word fix — @Autowired on the constructor you want:
public class TwoConstructorsAnnotated {
private final PaymentGateway gateway;
@Autowired
public TwoConstructorsAnnotated(PaymentGateway gateway) {
this.gateway = gateway;
Trace.log("annotated constructor used");
}
public TwoConstructorsAnnotated(PaymentGateway gateway, Notifier notifier) {
this.gateway = gateway;
Trace.log("two-arg constructor used");
}
}
Adding a convenience constructor can silently disable injection. If a class is wired by constructor injection and someone adds a second, no-argument constructor for a test, Spring may switch to it without any error. Mark the injection constructor with @Autowired whenever a class has more than one.
Going deeper: what Spring is doing when it chooses
For a class with a single constructor Spring treats that constructor as the injection point, which is why ConstructorOrderService needs no annotation. With several constructors and none marked, the container’s fallback is the default (no-argument) constructor if one exists; the message for the case where it does not is the one in the transcript — No default constructor found — with a NoSuchMethodException for <init>() underneath. A constructor marked @Autowired wins over any others.
The empty-collection behaviour covered in the @Autowired article also depends on this: a constructor that Spring treats as the single candidate is allowed to receive an empty list, while field and setter injection of the same list fail.
Going deeper
- Source: TwoConstructorsNoDefault.java (the failing variant)
- Reference: Constructor-based Dependency Injection
Circular dependencies: constructor injection makes the smell loud
Two beans that need each other cannot both be constructed first — that is the chicken-and-egg problem the Spring reference names. What is worth knowing is how each injection style, and each runtime, reacts. 08-circular-plain-vs-boot.txt runs the same pair of classes (CtorA.java / CtorB.java for constructors, FieldA.java / FieldB.java for fields) five ways:--- plain Spring, constructor cycle (CtorA <-> CtorB) ---
started: false
root cause: org.springframework.beans.factory.BeanCurrentlyInCreationException
Error creating bean with name 'ctorA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
--- plain Spring, field cycle (FieldA <-> FieldB) ---
started: true
FieldA.b is FieldB: true
--- Spring Boot, field cycle, default settings ---
FAILED: BeanCurrentlyInCreationException: Error creating bean with name 'fieldA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
--- Spring Boot, field cycle, spring.main.allow-circular-references=true ---
started
--- Spring Boot, constructor cycle, spring.main.allow-circular-references=true ---
FAILED: BeanCurrentlyInCreationException: Error creating bean with name 'ctorA': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?
spring.main.allow-circular-references=true lets the same field cycle start — and still fails the constructor cycle.
What a developer sees in a real Boot application is not that exception but a formatted report. The repository’s small application starts with a profile that contains the cycle, and 20-boot-failure-analysis-field-cycle.txt is what it prints:
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| bootCycles.FieldOne (field com.ankurm.coredi.boot.BootCycles$FieldTwo com.ankurm.coredi.boot.BootCycles$FieldOne.two)
↑ ↓
| bootCycles.FieldTwo (field com.ankurm.coredi.boot.BootCycles$FieldOne com.ankurm.coredi.boot.BootCycles$FieldTwo.one)
└─────┘
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.
“As a last resort.” Boot’s own message calls the property a last resort, and the constructor-cycle report ends with the same advice. The switch does not solve the design problem — two classes that need each other — it only tells the container to tolerate the half-built objects. It also does not help at all for a constructor cycle.If you truly cannot break the loop,
@Lazy on one constructor parameter defers one edge. The context starts (22-boot-lazy-cycle.txt), but check what was actually injected (LazyA.java):
context started: true
LazyA.b runtime class : com.ankurm.coredi.injection.LazyB$$SpringCGLIB$$0
LazyA.b.hello() : LazyB reached through LazyA's proxy
LazyA.b is the real LazyB bean: false
Going deeper: how Spring resolves an early reference, and why a constructor cycle cannot be resolved
Spring keeps three registries of singletons. Read straight from the bytecode of DefaultSingletonBeanRegistry in spring-beans-7.0.9.jar (24-early-reference-caches.txt):
private final java.util.Map<java.lang.String, java.lang.Object> singletonObjects;
private final java.util.Map<java.lang.String, org.springframework.beans.factory.ObjectFactory<?>> singletonFactories;
private final java.util.Map<java.lang.String, java.lang.Object> earlySingletonObjects;
private final java.util.Set<java.lang.String> singletonsCurrentlyInCreation;
singletonObjects holds finished beans. singletonFactories holds a factory that can produce a reference to a bean that has been instantiated but is not finished, and earlySingletonObjects caches what those factories produced. A field or setter cycle works because the first bean is instantiated, registered as an early reference, and only then does Spring go looking for its dependencies; the second bean receives the early reference of the first. A constructor cycle has no early reference to hand out: the first bean cannot be instantiated until it has the second, so it is never registered, and the second bean’s request finds the first “currently in creation” with nothing to return — hence the message you saw in the transcript, Requested bean is currently in creation.
The switch itself is a plain boolean on the bean factory:
private boolean allowCircularReferences;
public void setAllowCircularReferences(boolean);
public boolean isAllowCircularReferences();
The @Lazy identity trap. In 09-lazy-breaks-constructor-cycle.txt, LazyA.b is a LazyB$$SpringCGLIB$$0 — a proxy — and LazyA.b is the real LazyB bean: false. The proxy delegates every call to the real bean, so behaviour is normally the same, but == comparisons, equals implementations that compare classes, and anything that inspects the runtime type will see the proxy rather than the bean. It also postpones the cycle’s failure from start-up to first use if the real bean is later broken.
Going deeper
- Source: BootCycles.java (the three profile-gated cycles) and capture-failure-analysis.sh
- Reference: Spring Framework – Circular dependencies
- Related on this site: @Async in Spring Boot 4 and the Spring cache abstraction, which both depend on proxies that a self-referencing bean can bypass
So which style, and when
The Spring team’s own answer is in the reference guide, and it is unambiguous. The page on dependency injection states: “The Spring team generally advocates constructor injection, as it lets you implement application components as immutable objects and ensures that required dependencies are notnull. Furthermore, constructor-injected components are always returned to the client (calling) code in a fully initialized state.” That paragraph, read against the transcripts above, is a summary of them: immutability is the final=true row, “not null” is the missing-setter bug, and “fully initialized” is the half-built object.
@Autowired(required = false) setter, or an ObjectProvider covered in the next article — rather than leaving a required dependency to a setter and hoping. The dashed box is the one place field injection is still conventional: test classes, whose constructor is called by JUnit rather than by Spring, so @Autowired fields are how the framework hands them collaborators.
| Situation | Use | Why |
|---|---|---|
| A collaborator the class cannot work without | constructor | final field, never null, usable in the constructor, impossible to half-build |
| A collaborator that may not be configured | optional setter, Optional, ObjectProvider | the absence is written down in the type or the annotation |
| The class has more than one constructor | @Autowired on the intended one | otherwise Spring may pick the no-argument one silently |
| A cycle appeared | break the cycle; @Lazy only as a stopgap | the cycle is a design signal; the workaround hides it |
| A JUnit test class | @Autowired fields are fine | JUnit, not Spring, calls the constructor |
Should you even care, on a small project? Honestly, for a ten-class application with no unit tests, field injection will not hurt you, and nobody should rewrite working code for this alone. The case for constructors is cumulative: every class you will want to test without a container, every dependency you would like to be final, every cycle you would like the framework to flag at start-up. One further point is opinion, not something a transcript proves: a constructor with six or seven parameters is uncomfortable to read, and that discomfort is information — the class probably does too much. Field injection hides the same number of dependencies behind a shorter class, so the warning never appears.
Going deeper: the reference quote in context, and a nullability note for Spring 7
The passage quoted above appears in the dependency injection chapter of the Spring Framework reference under “Constructor-based Dependency Injection”. The same page recommends setters for optional dependencies, which is the branch in the diagram, and warns that predominantly constructor-based injection can produce an unresolvable circular dependency, which the previous section demonstrated. The page opens its explanation with “DI exists in two major variants: Constructor-based dependency injection and Setter-based dependency injection”; field injection is not presented as a third variant there.
One Spring 7 detail affects the optional branch. The Spring-specific org.springframework.lang.Nullable annotation is deprecated since 7.0, which 25-spring-nullable-deprecation.txt confirms by reading the class file:
#9 = Utf8 Deprecated
#10 = Utf8 RuntimeVisibleAnnotations
#22 = Utf8 Ljava/lang/Deprecated;
#23 = Utf8 since
Deprecated: true
RuntimeVisibleAnnotations:
java.lang.Deprecated(
since="7.0"
The companion repository uses org.jspecify.annotations.Nullable instead (OptionalConsumers.java), which is what Spring 7 itself now uses. Existing code with the old annotation still compiles; it just draws a deprecation warning.
Going deeper
- Next in the series: @Autowired Explained: By-Type Resolution, @Qualifier, @Primary, ObjectProvider and List Injection
- Then: Spring Bean Scopes and the Prototype-in-Singleton Trap and Spring Bean Lifecycle in Boot 4
- Migrating an older codebase: Spring Framework 6 to 7 Migration Guide
Further reading
- Companion repository for this article: asmhatre/spring-boot-demo, core-di module
- Other articles in this series: @Autowired Explained, Spring Bean Scopes, Spring Bean Lifecycle
- Interview preparation: Top 50 Spring Boot 4 Interview Questions and Answers (2026)
- Official reference: Spring Framework – Dependency Injection
- Official reference: Spring Framework – Using @Autowired
No Comments yet!