There is no startup failure. The application runs, and then a request dies with something like this (Java 17’s helpful NullPointerException message):
java.lang.NullPointerException: Cannot invoke "com.shop.pricing.TaxService.rateFor(String)" because "this.taxService" is null
at com.shop.pricing.PriceCalculator.total(PriceCalculator.java:18)
at com.shop.orders.OrderService.checkout(OrderService.java:41)taxService is annotated @Autowired, the TaxService bean exists, and you can inject it elsewhere without trouble. The message means that this particular PriceCalculator instance was never processed by Spring. Spring does not inject into classes; it injects into the instances it creates. If Spring did not create this object, or the field is not one it can fill, the field keeps its Java default: null.
Quick fix checklist
- Search for
new PriceCalculator((or whatever your class is). If you construct it yourself, Spring never sees it: inject it instead. - Is the field
static? Spring ignores static fields for@Autowiredand@Value. - Are you using the field inside the constructor? Field injection happens after construction.
- Is the class actually a bean (
@Component,@Service, a@Beanmethod) and inside the scanned packages? - Was the object created by another framework (Jackson, Hibernate, a thread pool task you built)? Pass dependencies in explicitly.
- Switch to constructor injection with
finalfields; the bug then becomes impossible to compile or fails at startup.
Before you start
You should know what a Spring bean is and the three injection styles (constructor, setter, field). This article targets Spring Boot 3.x on Java 17. The same reasoning applies to @Value, @Resource and @Inject.
Why it happens
Creating a bean is a sequence of steps performed by the ApplicationContext:
- Instantiate: call a constructor, passing resolved dependencies for constructor injection.
- Populate:
AutowiredAnnotationBeanPostProcessorfinds@Autowiredand@Valuefields and setters and assigns them. - Initialise: run
@PostConstructmethods and wrap the object in proxies where needed (transactions, async).
Every null-field bug is one of these steps not happening for the instance you are holding:
newskipped all three.new PriceCalculator()is plain Java. The annotation is just metadata nobody read.- The field was read during step 1. Code in the constructor runs before step 2, so field-injected values are still null there.
- Static fields are excluded from step 2. Spring injects per instance; it logs
Autowired annotation is not supported on static fieldsat INFO and moves on. - The class is not a bean. No stereotype annotation, outside the scanned packages, or abstract (scanning skips abstract classes, so
@Componenton an abstract base class does nothing for it). - Another framework created the object. Jackson creates DTOs while deserializing a request, Hibernate creates entities when loading rows, and your own
Runnablepassed to an executor is whatever you constructed. None of these go through the Spring container. Spring Boot does integrate with some of them: theObjectMapperthat Boot configures can autowire customJsonSerializerandJsonDeserializerclasses it instantiates, and Boot registers Spring as Hibernate’s bean container so JPA entity listeners andAttributeConverters can receive constructor injection. Entities and DTOs themselves are never injected.
The thread a call runs on is not the issue. A bean is just as injected on an @Async thread as on a request thread; what matters is who constructed the object.
Step-by-step walkthrough
Step 1: Reproduce the null
@Component
public class PriceCalculator {
@Autowired
private TaxService taxService;
public BigDecimal total(BigDecimal net, String country) {
return net.multiply(BigDecimal.ONE.add(taxService.rateFor(country)));
}
}
@Service
public class OrderService {
public BigDecimal checkout(Order order) {
PriceCalculator calculator = new PriceCalculator(); // the bug
return calculator.total(order.net(), order.country());
}
}PriceCalculator is a bean, and the bean Spring created has taxService set. But OrderService ignores that bean and builds a second, unmanaged instance.
Step 2: Ask who created the instance
Set a breakpoint on the failing line and inspect this. A Spring-managed bean that has been proxied shows a class name like PriceCalculator$$SpringCGLIB$$0, though plain beans have no suffix, so the suffix alone proves nothing. The reliable check is to search for new PriceCalculator and for anything that constructs it reflectively. In this case there is one hit, in OrderService.
Step 3: Let Spring create the object and use constructor injection
@Component
public class PriceCalculator {
private final TaxService taxService;
public PriceCalculator(TaxService taxService) {
this.taxService = taxService;
}
public BigDecimal total(BigDecimal net, String country) {
return net.multiply(BigDecimal.ONE.add(taxService.rateFor(country)));
}
}
@Service
public class OrderService {
private final PriceCalculator calculator;
public OrderService(PriceCalculator calculator) {
this.calculator = calculator;
}
public BigDecimal checkout(Order order) {
return calculator.total(order.net(), order.country());
}
}Now new PriceCalculator() no longer compiles, so the original mistake cannot come back. A missing TaxService bean fails at startup with “required a bean of type … that could not be found” instead of a null at runtime. With a single constructor, @Autowired is unnecessary.
Step 4: Fix constructor timing and static fields
If a class reads configuration in its constructor:
@Component
public class ReportClient {
@Value("${reports.base-url}")
private String baseUrl;
private final RestClient client;
public ReportClient(RestClient.Builder builder) {
this.client = builder.baseUrl(baseUrl).build(); // baseUrl is still null here
}
}Move the value into the constructor so it exists when you need it:
@Component
public class ReportClient {
private final RestClient client;
public ReportClient(RestClient.Builder builder,
@Value("${reports.base-url}") String baseUrl) {
this.client = builder.baseUrl(baseUrl).build();
}
}For static utility classes that “need” a bean, the fix is to stop being static: make the utility a bean and inject it. Assigning a bean to a static field from a @PostConstruct method works but hides a dependency and breaks tests that create several contexts.
Step 5: Handle framework-created objects
For a value the code receives from Jackson or Hibernate, do not try to inject into it. Pass the collaborator to the method that needs it, or move the behaviour into a service:
@Service
public class InvoiceService {
private final TaxService taxService;
public InvoiceService(TaxService taxService) {
this.taxService = taxService;
}
public BigDecimal grossFor(InvoiceRequest request) { // request created by Jackson
return request.net().multiply(BigDecimal.ONE.add(taxService.rateFor(request.country())));
}
}AspectJ load-time weaving with @Configurable can inject into new-created objects, but it adds build and runtime complexity that is rarely justified.
Worked scenario
A team writes a scheduled job that fans out work to a thread pool:
@Component
class NightlyExport {
private final ExecutorService pool = Executors.newFixedThreadPool(4);
@Scheduled(cron = "0 0 2 * * *")
void run() {
for (long customerId : List.of(1L, 2L, 3L)) {
pool.submit(new ExportTask(customerId));
}
}
}
class ExportTask implements Runnable {
@Autowired
private ExportRepository repository;
private final long customerId;
ExportTask(long customerId) {
this.customerId = customerId;
}
@Override
public void run() {
repository.save(new ExportRow(customerId)); // NullPointerException
}
}The developer suspects the worker threads (“Spring does not work on other threads”). The real cause is new ExportTask(customerId): the @Autowired annotation on a class that only ever gets constructed by hand is metadata nobody reads. A colleague suggests adding @Component to ExportTask, which makes things worse: Spring then tries to create its own instance, cannot resolve a long constructor argument, and startup fails, while the hand-made instances would still have a null repository.
The fix makes the task a plain object that receives everything it needs, and lets the bean pass its own dependency:
record ExportTask(ExportRepository repository, long customerId) implements Runnable {
@Override
public void run() {
repository.save(new ExportRow(customerId));
}
}
@Component
class NightlyExport {
private final ExportRepository repository;
private final ExecutorService pool = Executors.newFixedThreadPool(4);
NightlyExport(ExportRepository repository) {
this.repository = repository;
}
@Scheduled(cron = "0 0 2 * * *")
void run() {
for (long customerId : List.of(1L, 2L, 3L)) {
pool.submit(new ExportTask(repository, customerId));
}
}
}Worker threads were never the problem. (In a real application, use Spring’s TaskExecutor or @Async instead of an unmanaged pool, so the pool shuts down with the context.)
Common mistake
The most tempting wrong fix is adding more annotations: @Component on the class that is created with new, @Configurable without AspectJ weaving, or @ComponentScan on a random class. None of them change the fact that new bypasses the container.
The second is the static holder: a @Component that copies the ApplicationContext into a static field so any code can call getBean. It works until two test contexts run in the same JVM, and it hides dependencies from anyone reading the class. Use it only at true integration seams with legacy code.
The third is a null check (if (taxService != null)), which turns a loud bug into silently wrong prices.
Verify the behavior
Constructor injection lets you test the logic with plain Java, no Spring needed:
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class PriceCalculatorTest {
@Test
void addsTax() {
TaxService flatTax = country -> new BigDecimal("0.20");
PriceCalculator calculator = new PriceCalculator(flatTax);
assertThat(calculator.total(new BigDecimal("100"), "GB"))
.isEqualByComparingTo("120");
}
}This assumes TaxService is an interface with a single method, so a lambda can implement it. Then add a @SpringBootTest that injects OrderService and calls checkout, proving the real wiring. In the running app, the NullPointerException disappears from the logs; if you had any static @Autowired fields, the INFO line about static fields should also be gone after the refactor.
Interview exercise
“A class annotated @Service has an @Autowired field that is null in production but injected correctly in your @SpringBootTest. How is that possible, and how would you change the code so it cannot happen?”
Answer and reasoning
The test obtains the bean from the context, so Spring created and injected it. In production some code path constructs the class directly with new (or another framework creates it, or a static method reads a static field), so that instance never went through Spring’s populate step. Both instances exist at the same time: the injected bean that nobody uses on that path, and the unmanaged copy that fails. I would find the construction site, inject the bean there instead, and convert the class to constructor injection with final fields. That makes the dependency part of the type’s contract: any new call must now supply a TaxService, so the compiler shows every place that bypasses Spring, and a missing bean fails at startup instead of at the first request.