Adetayo Akinsanya unkletayo.dev

Dependency Injection Mechanics: Constructor, Field, and Setter Injection Trade-offs

Evaluating field injection pitfalls, constructor immutability, and circular dependency detection.

Part 3 in Series — Catch up on the previous article: Java Reflection Under the Hood: Classloading, Instantiation, and Metadata Inspection (Part 2) before diving into this post.

A developer creates a new feature service in a enterprise Spring Boot application:

@Service
public class UserService {
    @Autowired private UserRepository userRepository;
    @Autowired private AuditLogger auditLogger;
    @Autowired private EmailService emailService;
    @Autowired private SecurityValidator securityValidator;
    @Autowired private MetricsCollector metricsCollector;
    @Autowired private NotificationService notificationService;
}

The code looks concise.

However, when a QA engineer writes a pure JUnit unit test for UserService:

@Test
void testUserRegistration() {
    UserService userService = new UserService();
    userService.registerUser(new User("Alice"));
}

The test throws a NullPointerException on line 1.

Because field injection hid dependency requirements from the constructor signature, the QA engineer was forced to use Spring’s heavy test runner or reflection hacks to manually populate 6 private fields.

Later, when the application boots in production, Spring crashes immediately with:

Error creating bean with name 'userService': Requested bean is currently in creation: Is there an unresolvable circular dependency?

Why did using @Autowired on private fields lead to untestable classes and silent circular dependency bugs?

To design resilient applications, we must compare the mechanics and architectural trade-offs of Field Injection, Setter Injection, and Constructor Injection.


1. The Three Dependency Injection Variants

Spring supports three primary mechanisms for injecting dependencies into target beans:

                          DEPENDENCY INJECTION STYLES
                                       |
     +---------------------------------+---------------------------------+
     |                                 |                                 |
     v                                 v                                 v
[ 1. Field Injection ]        [ 2. Setter Injection ]       [ 3. Constructor Injection ]
@Autowired private Service;   @Autowired public void        public Service(Dep dep) {
                              setService(Dep dep) {         this.dep = dep; }

2. Field Injection: The Anti-Pattern

Field injection uses Reflection to write directly into private class fields annotated with @Autowired.

@Service
public class OrderService {
    @Autowired
    private PaymentProcessor paymentProcessor;
}

Why Field Injection Is Discouraged:

  1. Prevents Immutability: Fields annotated with @Autowired cannot be marked as final, allowing accidental state mutations after object creation.
  2. Hides Class Complexity: Because fields are annotated individually, developers can add 15 dependencies to a class without noticing that the class violates the Single Responsibility Principle.
  3. Forces Reflection in Unit Tests: Instantiating OrderService in a unit test without launching the full Spring IoC container leaves fields as null.
  4. Hides Circular Dependencies: Field injection defers circular dependency detection until runtime invocation, masking structural design flaws.

3. Setter Injection: Mutable & Optional Dependencies

Setter injection uses public annotated setter methods to inject dependencies after object instantiation:

@Service
public class OrderService {
    private AuditLogger auditLogger;

    @Autowired
    public void setAuditLogger(AuditLogger auditLogger) {
        this.auditLogger = auditLogger;
    }
}

When Setter Injection Is Appropriate:

  • Optional Dependencies: Useful for non-mandatory dependencies where reasonable defaults exist if no bean is provided.
  • Reconfigurability: Allows re-injecting dependencies dynamically at runtime (though rare in modern stateless microservices).

Drawback: Makes the bean mutable after initialization, risking thread-safety issues in concurrent environments.


4. Constructor Injection: The Industry Standard

Constructor injection requires dependencies to be passed explicitly as constructor arguments during object creation:

@Service
public class OrderService {
    private final PaymentProcessor paymentProcessor;
    private final AuditLogger auditLogger;

    // Single constructor: @Autowired is implicit in Spring 4.3+!
    public OrderService(PaymentProcessor paymentProcessor, AuditLogger auditLogger) {
        this.paymentProcessor = paymentProcessor;
        this.auditLogger = auditLogger;
    }
}

Why Constructor Injection Is the Preferred Pattern:

  1. Guarantees Immutability: Dependencies are assigned during object construction and stored in final fields, preventing thread-safety bugs.
  2. Fails Fast on Startup (Circular Dependencies): If ServiceA requires ServiceB in its constructor and ServiceB requires ServiceA, the JVM cannot construct either instance, causing Spring to fail fast during startup and pinpointing the exact architectural loop.
  3. Pure Java Unit Testing: Unit tests instantiate OrderService directly using standard new OrderService(mockPayment, mockLogger) without needing Reflection, Spring test runners, or annotation processors.
  4. Explicit Constructor Signatures: If a constructor requires 10 parameters, the code smell is immediately visible in the class signature, prompting developers to refactor.

Dependency Injection Comparison Matrix

Metric / DimensionField Injection (@Autowired)Setter Injection (set...())Constructor Injection
Field Immutability (final)NoNoYes (private final)
Unit Testability (Pure Java)❌ Hard (Requires Reflection)⚠️ Moderate (Calls setters)Easy (new Service(mock))
Circular Dependency CheckDeferred to runtime executionHandled post-instantiationFails Fast on Container Startup
Framework DecouplingDependent on Spring annotationsDependent on Spring annotations100% Plain Old Java Object (POJO)
Modern Spring StandardDiscouragedOptional / SecondaryIndustry Recommended Standard

Summary & Next Steps

Selecting the right dependency injection style dictates codebase testability and thread safety:

  • Field Injection masks class complexity, prevents final field immutability, and complicates pure Java unit tests.
  • Setter Injection accommodates optional or reconfigurable dependencies but introduces object mutability risks.
  • Constructor Injection guarantees final field immutability, enables fast circular dependency detection on startup, and supports pure Java unit testing without framework reflection.

In the next article, we put these principles into practice by Building a Custom IoC Container in Java: Reflection-Based Dependency Wiring.

References & Further Reading

  1. Spring.io. BeanFactory vs ApplicationContext Architecture. Spring Docs.
  2. Spring.io. Spring API Documentation: BeanFactory & ApplicationContext. Spring Docs.
  3. Walls, C. (2022). Spring in Action (6th Edition) — Chapter 1: Getting Started with Spring. Manning.

Up Next in Series →

Part 4: Building a Custom IoC Container in Java: Reflection-Based Dependency Wiring

Continue to Part 4 →