Ch. 8 · Spring Boot

Spring @Autowired Field Is Null: Why Injection Didn't Happen

Why is an @Autowired or @Value field null in Spring Boot? Objects built with new, static fields and constructor timing, and how to fix them.

~8 min readbeginnerupdated Oct 4, 2026

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)
Text

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 @Autowired and @Value.
  • Are you using the field inside the constructor? Field injection happens after construction.
  • Is the class actually a bean (@Component, @Service, a @Bean method) 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 final fields; 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:

  1. Instantiate: call a constructor, passing resolved dependencies for constructor injection.
  2. Populate: AutowiredAnnotationBeanPostProcessor finds @Autowired and @Value fields and setters and assigns them.
  3. Initialise: run @PostConstruct methods 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:

  • new skipped 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 fields at INFO and moves on.
  • The class is not a bean. No stereotype annotation, outside the scanned packages, or abstract (scanning skips abstract classes, so @Component on 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 Runnable passed to an executor is whatever you constructed. None of these go through the Spring container. Spring Boot does integrate with some of them: the ObjectMapper that Boot configures can autowire custom JsonSerializer and JsonDeserializer classes it instantiates, and Boot registers Spring as Hibernate’s bean container so JPA entity listeners and AttributeConverters 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());
    }
}
java

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());
    }
}
java

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
    }
}
java

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();
    }
}
java

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())));
    }
}
java

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
    }
}
java

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));
        }
    }
}
java

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");
    }
}
java

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.

Continue learning

More in Spring Boot

read ✓Spring Boot · hard

Spring @Async and Executor Configuration

Run methods asynchronously with @Async, configure a bounded executor, and handle exceptions and the proxy boundary.

~2 min readread →
esc