Dependency Injection (DI) is a design pattern and the practical application of the Dependency Inversion Principle (DIP). It removes hard-coded coupling between components by having dependencies supplied externally rather than constructed internally. Mastering DI is essential for writing testable, maintainable code at scale.
Dependency Inversion Principle
The Dependency Inversion Principle states: high-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details — details should depend on abstractions. Without DIP, a service class that directly instantiates a repository creates an unbreakable link between business logic and data access implementation. With DIP, the service depends on a RepositoryInterface, and the concrete repository depends on that same interface. Swapping implementations — in-memory for tests, SQL for production — requires zero changes to the service.
DI vs Service Locator
Both DI and the Service Locator pattern decouple components from their dependencies, but they do so with very different tradeoffs.
- Dependency Injection: dependencies are injected by the caller or container. Dependencies are explicit — visible in the constructor or method signature. The component has no knowledge of the container. This makes dependencies easy to mock in unit tests.
- Service Locator: the component pulls its dependencies from a central registry (
ServiceLocator.get(MyService.class)). Dependencies are hidden inside the implementation — you cannot tell what a class needs without reading its body. Testing requires a populated registry. Considered an antipattern in most modern architectures.
Constructor Injection
Constructor injection passes dependencies as constructor parameters. This is the recommended form of DI for mandatory dependencies. It enforces completeness — an object cannot be created in a partially initialized state. It naturally supports immutability (fields can be final). It makes the dependency graph explicit at a glance. Every modern DI framework (Spring, Guice, .NET DI) supports and prefers constructor injection.
// Java — constructor injection
public class OrderService {
private final PaymentGateway gateway;
private final InventoryRepository inventory;
public OrderService(PaymentGateway gateway, InventoryRepository inventory) {
this.gateway = gateway;
this.inventory = inventory;
}
}
Setter Injection
Setter injection calls setters after the object is constructed. It is useful for optional dependencies or when a dependency must be resolved after construction (e.g., circular graphs in legacy code). The downside: the object can exist in a partially initialized state before all setters are called, which can cause NullPointerExceptions if setters are skipped. Setter injection is appropriate for optional collaborators or plugin-style extension points, but should not be the default choice for required dependencies.
Field Injection
Field injection uses framework annotations (Spring’s @Autowired, Jakarta’s @Inject) directly on fields. The framework reflectively sets private fields. This is the most concise form but carries serious drawbacks: dependencies are invisible in the API, classes cannot be instantiated without the framework (breaking unit tests), and fields cannot be final. Field injection is convenient for quick prototypes but is discouraged in production-grade code. The Spring team itself recommends constructor injection over field injection.
IoC Container Responsibilities
An Inversion of Control (IoC) container automates the wiring of object graphs. Its three core responsibilities are:
- Registration: map interfaces to concrete implementations (
bind(PaymentGateway.class).to(StripeGateway.class)). - Instantiation and wiring: when a component is requested, the container resolves its full dependency tree recursively and injects them.
- Lifetime management: control how long instances live — singleton, transient, or scoped.
Lifetime Management
Lifetime management is one of the most common sources of bugs in DI-heavy applications. The three standard lifetimes:
- Singleton: one instance per container. Shared across all consumers. Suitable for stateless services (configuration readers, HTTP clients, connection pools). Thread-safety is mandatory.
- Transient: a new instance is created every time the dependency is requested. Suitable for lightweight, stateful objects. Watch for accidental capture of transient instances in singletons ("captive dependency" bug).
- Scoped: one instance per defined scope — typically one per HTTP request in web applications. The scope is created at request start and disposed at request end. A scoped service injected into a singleton is a captive dependency bug: the short-lived object is held longer than intended.
Circular Dependency Problem
A circular dependency occurs when A depends on B and B depends on A (directly or transitively). With constructor injection, the container cannot instantiate either class — it’s a deadlock. Resolution strategies:
- Refactor to break the cycle: extract a third class C that both A and B depend on.
- Introduce a mediator or interface: have A depend on an
IBEventPublisherinterface that B implements, decoupling the direction. - Setter or property injection: inject one direction lazily so construction can complete before wiring the cycle.
- Lazy dependency: inject
Lazy<B>into A — B is only resolved when first accessed, breaking the construction cycle.
Spring DI
Spring Framework is the most widely used DI container in the Java ecosystem. Key concepts: the ApplicationContext is the IoC container. Beans are registered via XML config (legacy), @Component scanning with @Autowired, or Java-based @Configuration classes with @Bean factory methods. @Component, @Service, @Repository, and @Controller are stereotype annotations that trigger component scanning. Spring also integrates AOP (Aspect-Oriented Programming) — cross-cutting concerns like transactions (@Transactional) and security are woven into beans as proxies by the container. Spring Boot’s auto-configuration extends this by automatically registering well-known beans based on classpath presence.
Google Guice
Guice is a lightweight DI framework for Java developed at Google. Configuration is done in Module classes by overriding configure() and calling bind(Interface.class).to(Implementation.class). The @Inject annotation (JSR-330) marks constructors, setters, or fields for injection. For objects that require runtime parameters or complex construction, Guice uses Provider<T> — a factory that the container calls each time a transient instance is needed. Guice is more explicit than Spring — there’s no classpath scanning magic by default — which makes the dependency graph easier to audit.
.NET Dependency Injection and Testing Benefits
Microsoft.Extensions.DependencyInjection ships with ASP.NET Core and is the standard DI system for .NET. Services are registered on an IServiceCollection using AddSingleton<TService, TImpl>(), AddTransient<>(), or AddScoped<>(), then compiled into an IServiceProvider. Constructor injection is the only supported injection style (no field injection), enforcing clean design.
The primary testing benefit of DI is that no framework is required for unit tests. Instantiate the class under test directly with mock implementations passed to the constructor. There is no need to bootstrap a container, configure a registry, or use reflection. This keeps unit tests fast, isolated, and straightforward. Integration tests can spin up a test container with selected real or stub implementations swapped in.
See also: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering
See also: Atlassian Interview Guide