Dependency Injection in Java — Circular Refs That Crash
BeanCurrentlyInCreationException kills startup when two beans inject each other via constructor.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Dependency Injection (DI) is a technique where an external container provides a class its dependencies rather than the class creating them itself
- Three injection styles: constructor (most preferred), setter (optional deps), field (fragile, test-unfriendly)
- IoC container scans class dependencies, builds a graph, and wires them at startup
- Spring uses reflection to inject beans: constructor injection avoids reflection overhead for required deps
- Biggest mistake: circular dependencies — they compile but throw BeanCurrentlyInCreationException at runtime
- Performance insight: field injection uses reflection every time — for high-throughput beans, prefer constructor injection
Dependency Injection (DI) is a technique where an object receives other objects it depends on from an external source, rather than creating them itself. This external source is called an Inversion of Control (IoC) container. The term 'inversion' refers to the shifted responsibility: your class no longer controls dependency creation — the container does.
In Java, DI is implemented via three primary injection styles: constructor injection (dependencies passed via the constructor), setter injection (dependencies set via setter methods), and field injection (dependencies injected directly onto private fields via reflection).
Constructor injection is the most reliable — it ensures the object is fully initialized upon creation and enables immutability. Field injection, while convenient, introduces testability problems because you can't easily provide mocks without the container. Setter injection sits in the middle: useful for optional dependencies but requires the object to tolerate a partially constructed state.
Imagine you run a coffee shop. Instead of your barista going out to buy milk every morning, a supplier just delivers it to the door. The barista doesn't care where the milk came from — they just use it. Dependency Injection works the same way: instead of your class hunting down its own dependencies (like database connections or services), something else just hands them over. Your class stays focused on its actual job, and swapping the 'milk supplier' later requires zero changes to the barista.
Every Java application beyond 'Hello World' has objects that depend on other objects. A UserService needs a UserRepository. A PaymentProcessor needs a NotificationClient. The way you wire those relationships together determines how testable, maintainable, and scalable your codebase will be — arguably more than any other single design decision. Get it wrong and you end up with a tightly-coupled monolith where changing one class breaks five others and mocking anything in a unit test requires heroic effort.
Dependency Injection (DI) solves the coupling problem by inverting control: instead of a class creating or locating its own dependencies, an external mechanism provides them. This is the practical application of the Dependency Inversion Principle (the D in SOLID). The result is code where each class declares what it needs without caring how those needs are fulfilled — making it trivially easy to swap implementations, inject mocks in tests, and reason about each class in isolation.
By the end of this article you'll understand the three injection styles and when each one is appropriate, how an IoC container actually resolves a dependency graph at runtime, how Spring implements DI under the hood, and the real-world gotchas that trip up experienced engineers — circular dependencies, prototype beans inside singletons, and the performance cost of reflection-based injection. You'll leave with patterns you can apply tomorrow morning.
What Is Dependency Injection?
Dependency Injection (DI) is a technique where an object receives other objects it depends on from an external source, rather than creating them itself. This external source is called an Inversion of Control (IoC) container. The term 'inversion' refers to the shifted responsibility: your class no longer controls dependency creation — the container does.
In Java, DI is implemented via three primary injection styles: constructor injection (dependencies passed via the constructor), setter injection (dependencies set via setter methods), and field injection (dependencies injected directly onto private fields via reflection).
Constructor injection is the most reliable — it ensures the object is fully initialized upon creation and enables immutability. Field injection, while convenient, introduces testability problems because you can't easily provide mocks without the container. Setter injection sits in the middle: useful for optional dependencies but requires the object to tolerate a partially constructed state.
package io.thecodeforge.di; // Constructor injection: dependencies are explicit and mandatory public class PaymentService { private final NotificationClient notificationClient; private final TransactionRepository transactionRepository; public PaymentService(NotificationClient notificationClient, TransactionRepository transactionRepository) { this.notificationClient = notificationClient; this.transactionRepository = transactionRepository; } public void process(Order order) { transactionRepository.save(order); notificationClient.send(order.customerEmail(), "Payment processed"); } } // Field injection: fragile for testing @Component public class FragileService { @Autowired private NotificationClient notificationClient; public void notify(String message) { notificationClient.send(message); // NPE if client not injected } }
- Your class declares what it needs (constructor parameters)
- The container decides when and how to create those ingredients (beans)
- If the supplier changes (different implementation), the chef never notices
- Testing = substitute the real ingredients with fake ones (mocks)
How an IoC Container Resolves the Dependency Graph
When you annotate a class with @Component, @Service, @Repository, or @Controller, Spring's IoC container picks it up during component scanning. It then builds a dependency graph by inspecting each bean's constructors, fields, and setters (depending on the injection strategy).
The container uses a process called 'bean post-processing' to determine the order of instantiation. It first creates beans with no dependencies, then progressively creates those that depend on already-created beans. This is essentially a topological sort of the dependency graph.
If a circular dependency is detected — bean A needs bean B which needs bean A — Spring will throw a BeanCurrentlyInCreationException at startup, unless one of the dependencies uses @Lazy (which breaks the cycle by deferring the creation of the lazy bean until it's actually accessed). Under the hood, Spring uses a 'singleton currently in creation' set to track beans during construction.
package io.thecodeforge.container; import java.util.*; // A simplified illustration of how an IoC container resolves dependencies public class ContainerSimulation { private final Map<Class<?>, Object> singletons = new HashMap<>(); private final Set<Class<?>> currentlyInCreation = new HashSet<>(); @SuppressWarnings("unchecked") public <T> T getBean(Class<T> beanClass) { if (singletons.containsKey(beanClass)) { return (T) singletons.get(beanClass); } if (currentlyInCreation.contains(beanClass)) { throw new RuntimeException("Circular dependency detected for: " + beanClass.getName()); } currentlyInCreation.add(beanClass); // In a real container, this would inspect constructors and resolve dependencies T instance = createInstance(beanClass); currentlyInCreation.remove(beanClass); singletons.put(beanClass, instance); return instance; } private <T> T createInstance(Class<T> beanClass) { // Placeholder: real container uses reflection to call constructor with resolved args try { return beanClass.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new RuntimeException("Cannot instantiate: " + beanClass.getName(), e); } } }
AbstractAutowireCapableBeanFactory.populateBean() is where the magic happens. It processes @Autowired fields, @Inject annotations, and setter methods. Constructor resolution happens earlier in AutowireUtils.resolveAutowiring() using the same topological logic.Spring's Autowiring and Qualifiers
Spring's autowiring resolves dependencies by type first, then by qualifier if multiple beans of the same type exist. When a constructor parameter type matches exactly one bean, Spring injects it. If there are multiple beans, Spring tries to match the parameter name to the bean name — and if the name doesn't match, you must use @Qualifier.
This is a common source of head-scratching bugs: you add a new implementation of an interface, and suddenly startup fails with 'NoUniqueBeanDefinitionException' because Spring doesn't know which one to use. The fix is to mark one bean as @Primary or to use @Qualifier on the injection point.
For collections, Spring supports injection of all beans of a given type into a List<Interface>, which is incredibly useful for chain-of-responsibility patterns or multi-algorithm strategies.
package io.thecodeforge.injection; import org.springframework.context.annotation.*; @Configuration public class NotificationConfig { @Bean @Primary public NotificationClient emailClient() { return new EmailNotificationClient(); } @Bean public NotificationClient smsClient() { return new SmsNotificationClient(); } } // In service: @Service public class NotificationService { private final NotificationClient primaryClient; // gets emailClient due to @Primary private final List<NotificationClient> allClients; // gets both public NotificationService(NotificationClient primaryClient, List<NotificationClient> allClients) { this.primaryClient = primaryClient; this.allClients = allClients; } public void broadcast(String message) { allClients.forEach(c -> c.send(message)); } }
Circular Dependencies: The Silent Startup Killer
A circular dependency occurs when Bean A depends on Bean B, and Bean B depends directly or indirectly on Bean A. Spring detects this during container initialization and throws BeanCurrentlyInCreationException. The fix is never to 'fix' the annotation; fix the design.
Three proven strategies to break circular dependencies: 1. Extract the shared logic into a third bean that both depend on (Mediator pattern). 2. Use setter injection with @Lazy on one side — this defers the creation of the lazy bean until it's actually needed, but beware: if you call a method on the lazy bean before its dependencies are resolved, you get a NullPointerException. 3. Redesign the architecture: circular dependencies almost always violate the Single Responsibility Principle. Maybe both beans should be merged, or an event-driven approach (ApplicationEventPublisher) would be cleaner.
In large legacy codebases, you'll often encounter indirect cycles (A → B → C → A). These are harder to spot. Use the startup debug log: 'spring.beaninfo.ignore=true' and check for 'Currently in creation' lines.
package io.thecodeforge.di; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; // BAD: circular constructor injection (will fail) // @Service // public class OrderService { // private final CustomerService customerService; // CustomerService needs OrderService // } // GOOD: break the cycle with a third service @Service public class OrderService { private final LoyaltyService loyaltyService; public OrderService(LoyaltyService loyaltyService) { this.loyaltyService = loyaltyService; } } @Service public class CustomerService { private final LoyaltyService loyaltyService; public CustomerService(LoyaltyService loyaltyService) { this.loyaltyService = loyaltyService; } } // LOOSE COUPLING via events: ApplicationEventPublisher avoids any direct dependency
Performance Cost of Reflection-Based Injection
Spring's DI relies heavily on Java Reflection: scanning classes, inspecting constructors, fields, and annotations, and invoking methods reflectively. For startup, this is acceptable — the overhead is in the order of tens of milliseconds for a typical microservice. However, for request-scoped beans (prototype or request scope) that are created on every request, reflection-based field injection adds measurable latency.
Consider a prototype bean with 10 fields injected via @Autowired. Each field injection requires: field lookup by name, setting accessibility, and field.set() operation. That's ~200μs per prototype bean. Under 5000 requests/sec, that's an extra second per second of CPU time solely for injection.
Constructor injection avoids this because the container calls the constructor with resolved arguments using Constructor.newInstance() — a single reflective call for the entire bean. The difference is an order of magnitude for prototype-scoped beans.
Spring 6 and Spring Boot 3 have improved reflection caching, but the principle remains: constructor injection is not just cleaner design — it's measurably faster under load.
package io.thecodeforge.performance; // Conceptual benchmark comparison public class InjectionBenchmark { // Field injection: ~2μs per field // 10 fields -> ~20μs per instance of a prototype bean // Constructor injection: ~5μs for the entire bean (one reflective call) // 10 fields -> still ~5μs // At 5000 req/s with prototype beans: // Field injection: 5000 * 20μs = 100ms/s overhead // Constructor injection: 5000 * 5μs = 25ms/s overhead // That's 4x less CPU spent on injection. }
Field.set() calls. Switching to constructor injection dropped it to 1.5% and improved p99 latency by 3ms.Why Injecting Interfaces is the Only Safe Choice in Production
Most tutorials show you how to inject a concrete class and call it DI. That's not DI. That's fancy instantiation. Real dependency injection only works when you program to interfaces. Why? Because concrete classes chain you to their implementation details. Swap a JDBC connection pool? Now you're rewriting every injection point. Stub a service for integration tests? Can't, if the constructor expects a concrete MySqlPaymentGateway. The interface is your contract. The implementation is just a detail you can replace without touching consumers. In production, your DI container resolves the interface to a concrete bean based on qualifiers, profiles, or even runtime conditions. That's the whole point of “Don’t call us, we’ll call you.” Your code should never, ever instantiate its own dependencies. If I see new inside a service class during a code review, that PR gets rejected on the spot. Every class that needs a collaborator should declare that need via an interface in its constructor or setter. That keeps your system decoupled, testable, and deployable without a rewrite every time a vendor changes their API.PaymentGateway()
// io.thecodeforge — java tutorial import java.math.BigDecimal; // The contract — no implementation details, just capability interface PaymentGateway { boolean charge(BigDecimal amount, String currency); } // Production implementation class StripeGateway implements PaymentGateway { @Override public boolean charge(BigDecimal amount, String currency) { System.out.println("Stripe: charging " + amount + " " + currency); return true; } } // Consumer depends on the interface, not the concrete class class OrderService { private final PaymentGateway gateway; // Injection point — container provides the implementation public OrderService(PaymentGateway gateway) { this.gateway = gateway; } public void checkout(BigDecimal total) { if (!gateway.charge(total, "USD")) { System.out.println("Payment failed"); } System.out.println("Checkout complete"); } } public class PaymentProcessorWithInterface { public static void main(String[] args) { // Simulate DI container wiring PaymentGateway gateway = new StripeGateway(); OrderService service = new OrderService(gateway); service.checkout(new BigDecimal("49.99")); } }
OrderService is now coupled to StripeGateway. When your boss asks to switch to Adyen, every test and production path breaks.Constructor Injection is Production-Ready; Field Injection is Technical Debt
You see it everywhere: @Autowired slapped on a private field. Clean, concise, wrong. Field injection hides dependencies. Your class looks like it has none until Spring calls new through reflection, leaving the field null until the proxy is fully constructed. That means you can’t test it without the container. Constructor injection makes dependencies explicit. Every constructor parameter is a dependency your class admits it needs. No surprises. No null pointer exceptions because the container missed a field. The class is immutable after construction and always in a valid state. In production, your build tool (SpotBugs, Checkstyle) can verify that every field is final and set in the constructor. Field injection defeats static analysis. Use it in throwaway prototypes if you must. In production code, you’re asking for runtime failures that compile-time checks would have caught. Spring’s own docs recommend constructor injection. When you see a colleague using field injection, send them here. It’s not style. It’s correctness.
// io.thecodeforge — java tutorial import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; // BAD: Field injection — dependencies are invisible and mutation-prone @Component class ReportServiceFieldInjected { @Autowired private DataRepository repository; public void generate() { // Can NPE if Spring didn't wire repository System.out.println(repository.fetch()); } } // GOOD: Constructor injection — explicit, final, testable @Component class ReportServiceConstructorInjected { private final DataRepository repository; // Spring calls this constructor with the resolved dependency public ReportServiceConstructorInjected(DataRepository repository) { this.repository = repository; } public void generate() { System.out.println(repository.fetch()); } } // Stub for demonstration class DataRepository { public String fetch() { return "production data"; } } public class ConstructorVsFieldInjection { public static void main(String[] args) { // Manual wiring — works for constructor injection DataRepository repo = new DataRepository(); ReportServiceConstructorInjected service = new ReportServiceConstructorInjected(repo); service.generate(); // Field-injected version cannot be tested this way — requires Spring Context // ReportServiceFieldInjected bad = new ReportServiceFieldInjected(); // bad.generate(); // NPE! } }
Scoping: How Singletons, Prototypes, and Request Scopes Actually Behave Under Load
Every DI framework defaults to singleton scoping. That means one bean instance lives for the entire application context. It’s fast and thread-safe if the bean is stateless. But throw a stateful bean into a singleton scope and you’re debugging race conditions at 3 AM. Prototype scope creates a new instance every time you request a bean. Period. Useful for objects that hold request-specific state. But if a singleton bean injects a prototype bean, guess what? That prototype is instantiated once when the singleton is created, not when you call a method. The container only creates new prototypes when you call applicationContext.getBean() or use @Lookup or javax.inject.Provider. In a web app, request and session scopes keep beans alive for the duration of an HTTP request or user session. These rely on proxy mode (scoped-proxy="target-class" in XML or @Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)). Without the proxy, a singleton that injects a request-scoped bean will hold a stale instance. I’ve seen that bug sink a production deployment. Know your scope lifetimes. Over-scope to singleton, you share state. Under-scope to prototype, you leak memory. Pick the right scope for the job.
// io.thecodeforge — java tutorial import org.springframework.beans.factory.annotation.Lookup; import org.springframework.context.annotation.*; import org.springframework.stereotype.Component; // Prototype bean — new instance each time @Component @Scope("prototype") class RequestContext { private final String id = java.util.UUID.randomUUID().toString(); public String getId() { return id; } } // Singleton bean — lives once across the app @Component @Scope("singleton") class TrackerService { // BAD: Direct injection of prototype into singleton — captured once @Autowired private RequestContext context; public void track() { // This prints the same UUID every time! Context is not fresh System.out.println("Tracker context ID: " + context.getId()); } } // CORRECT: Use Provider or @Lookup to get a new prototype each call @Component @Scope("singleton") class TrackerServiceCorrect { @Lookup public RequestContext getContext() { return null; } // Spring overrides this public void track() { System.out.println("TrackerCorrect context ID: " + getContext().getId()); } } @Configuration @ComponentScan public class ScopeTrapExample { public static void main(String[] args) throws Exception { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(ScopeTrapExample.class); TrackerService bad = ctx.getBean(TrackerService.class); bad.track(); bad.track(); // Same UUID — broken TrackerServiceCorrect good = ctx.getBean(TrackerServiceCorrect.class); good.track(); good.track(); // Different UUID each time ctx.close(); } }
Stop Wiring by Hand: How to Build a Custom Injector That Won't Embarrass You in Production
Your team is wasting time wiring objects manually or cargo-culting Spring annotations without understanding what happens underneath. That's how you get startups that fail silently at 3 AM. You need to know how to build a minimal DI container from scratch—not to replace Spring, but to understand why Spring works the way it does.
The core mechanic is simple: scan constructors, resolve dependencies recursively, cache singletons. Stop pretending this is magic. A production-grade injector does exactly three things: reflect on constructor parameters, walk the dependency graph depth-first, and throw a hard error on cycles before startup completes.
Here's a bare-bones injector that handles the critical path. No qualifiers, no scopes—just raw dependency resolution with cycle detection. This is the skeleton your tools wrap in annotations and XML. Understand this, and you stop fighting your framework.
// io.thecodeforge — java tutorial import java.lang.reflect.*; import java.util.*; public class CustomInjector { private final Map<Class<?>, Object> singletons = new HashMap<>(); private final Set<Class<?>> resolving = new HashSet<>(); // cycle guard @SuppressWarnings("unchecked") public <T> T resolve(Class<T> clazz) { if (singletons.containsKey(clazz)) return (T) singletons.get(clazz); if (!resolving.add(clazz)) throw new RuntimeException("Cycle: " + clazz); try { Constructor<?> ctor = clazz.getDeclaredConstructors()[0]; Object[] args = Arrays.stream(ctor.getParameterTypes()) .map(this::resolve).toArray(); T instance = (T) ctor.newInstance(args); singletons.put(clazz, instance); return instance; } catch (Exception e) { throw new RuntimeException("Inject failed: " + clazz, e); } finally { resolving.remove(clazz); } } public static void main(String[] args) { var injector = new CustomInjector(); System.out.println(injector.resolve(Service.class).work()); } } class Service { private final Repo repo; public Service(Repo repo) { this.repo = repo; } public String work() { return repo.fetch(); } } class Repo { public String fetch() { return "data"; } }
Stop Guessing: Profile the Cost of Reflection Before It Hits Production
Every time you let Spring scan another package or deploy another prototype bean, you're adding startup tax. New devs treat this as free. It's not. Reflection-based constructor resolution costs roughly 0.5–2 µs per bean on modern JVMs—that's fine for 200 beans, but disastrous for 2,000 with mixed scopes.
The real killer? Prototype scope. Each getBean() call re-resolves constructors reflectively. If your request-scoped controller injects a prototype service, that's a reflection hit per request. Under 10k RPM, those microseconds compound into seconds of CPU time wasted on metadata parsing.
Here's a benchmark that proves the gap. Run this against your own code before you argue with me. Measure the difference between direct instantiation and reflective instantiation. If your service layer takes more than 5ms to inject under load, you have a scaling problem that no amount of caching fixes.
// io.thecodeforge — java tutorial import java.lang.reflect.Constructor; public class ReflectionBenchmark { static class Payload { public Payload(String a, int b) {} } public static void main(String[] args) throws Exception { // Warmup for (int i = 0; i < 10_000; i++) { new Payload("x", 1); Constructor<?> c = Payload.class.getConstructors()[0]; c.newInstance("x", 1); } long direct = System.nanoTime(); for (int i = 0; i < 100_000; i++) new Payload("x", 1); long directTime = System.nanoTime() - direct; long reflect = System.nanoTime(); Constructor<?> c = Payload.class.getConstructors()[0]; for (int i = 0; i < 100_000; i++) c.newInstance("x", 1); long reflectTime = System.nanoTime() - reflect; System.out.println("Direct: " + directTime / 100_000 + " ns"); System.out.println("Reflect: " + reflectTime / 100_000 + " ns"); System.out.println("Overhead: " + ((reflectTime - directTime) / 100_000) + " ns per bean"); } }
-Dsun.reflect.inflationThreshold=0 and add --add-opens java.base/java.lang=ALL-UNNAMED to let the JIT inline reflective calls after warmup. This cuts reflection overhead by ~70% after the first 15 invocations per constructor.Circular Dependency Causes Production Crash on Startup
- Never create circular constructor dependencies — they are a design smell, not a configuration problem.
- Use @Lazy only as a temporary escape hatch; it defers the problem and can cause NullPointerExceptions at runtime.
- Run a dependency graph analysis tool (e.g., IntelliJ Dependency Visualization) before every major release.
grep -r "@Component" io/thecodeforge/repository/Verify component-scan path in @SpringBootApplication annotation.Enable debug logs: logging.level.org.springframework.beans.factory=DEBUGCheck for missing @Primary or @Qualifier on the injected field/parameter.Check startup logs for the cycle: look for 'Currently in creation' chain.Visualize dependencies: IntelliJ → Diagrams → Show Dependencies for the class.| Aspect | Constructor Injection | Setter Injection | Field Injection |
|---|---|---|---|
| Immutability | Yes — dependencies can be final | No — setters allow reassignment | No — fields are mutable |
| Testability without container | Excellent — just call new with mocks | Good — but object may be incomplete | Poor — requires reflection or container |
| Optional dependencies | Not suitable (use Java Optional?) | Best suited | Possible but still fragile |
| Circular dependency detection | Fail fast at startup | May hide cycle (late initialization) | May hide cycle (runtime NPE) |
| Reflection overhead (per bean) | Single reflective constructor call | Multiple setter invocations | Multiple field set operations |
| Code clarity | Explicit — all dependencies visible | Less explicit — object may be half-built | Invisible — dependencies hidden |
| File | Command / Code | Purpose |
|---|---|---|
| io | public class PaymentService { | What Is Dependency Injection? |
| io | public class ContainerSimulation { | How an IoC Container Resolves the Dependency Graph |
| io | @Configuration | Spring's Autowiring and Qualifiers |
| io | @Service | Circular Dependencies |
| io | public class InjectionBenchmark { | Performance Cost of Reflection-Based Injection |
| PaymentProcessorWithInterface.java | interface PaymentGateway { | Why Injecting Interfaces is the Only Safe Choice in Producti |
| ConstructorVsFieldInjection.java | @Component | Constructor Injection is Production-Ready; Field Injection i |
| ScopeTrapExample.java | @Component | Scoping |
| CustomInjector.java | public class CustomInjector { | Stop Wiring by Hand |
| ReflectionBenchmark.java | public class ReflectionBenchmark { | Stop Guessing |
Key takeaways
Common mistakes to avoid
4 patternsMemorising syntax before understanding the concept
Skipping practice and only reading theory
Using field injection everywhere for convenience
Not understanding bean scopes and injection timing
Interview Questions on This Topic
What are the three types of dependency injection in Spring? When would you use each?
How does Spring detect and handle circular dependencies?
Explain the difference between Spring's Singleton scope and the Gang of Four Singleton pattern.
How would you inject a prototype-scoped bean into a singleton-scoped bean and get a new instance every time?
Frequently Asked Questions
Dependency Injection is a technique where an object receives other objects it depends on (its dependencies) from an external source — typically a framework container — instead of creating them itself. This makes the code more modular, testable, and maintainable because you can swap implementations without changing the consuming class.
@Autowired is Spring-specific; @Inject is from the Java CDI specification (JSR-330). Functionally they are nearly identical: both can be placed on fields, setters, or constructors. @Inject does not support the 'required' attribute that @Autowired supports. Spring recommends using @Inject for portability and @Autowired only when you need the 'required' attribute or when using Spring-specific features like @Qualifier with custom resolution.
Spring maintains a set of beans currently being created. When it tries to create a bean, it adds its name to the set. If during creation of that bean it encounters a dependency that requires it again, it finds the bean name in the set and throws the exception to prevent infinite recursion. This only happens with constructor injection because Spring cannot complete construction without all constructor arguments.
Yes. You can manually implement the IoC container pattern or simply pass dependencies through constructors and wire them in a 'composition root' — a central location where you create the object graph. Many small applications do this without Spring. However, as the project grows, a framework like Spring or Guice saves significant boilerplate and provides lifecycle management.
Constructor injection allows you to create the object in a test with simple new MyService(mockDep1, mockDep2). No framework needed. Field injection requires either SpringRunner to bootstrap the Spring context (slow) or Mockito's @InjectMocks with reflection (still less explicit). Constructor injection makes dependencies explicit and mandatory, which leads to better test design.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Advanced Java. Mark it forged?
7 min read · try the examples if you haven't