Skip to content
Home
Dependency Injection: Principles and Patterns

Dependency Injection: Principles and Patterns

Software Design Software Design 8 min read 1560 words Beginner ExcellentWiki Editorial Team

Dependency injection is a technique where an object receives its dependencies from an external source rather than creating them itself. It is a specific form of inversion of control — instead of a class controlling its dependencies, the control is inverted to an external entity.

Dependency injection makes code more modular, testable, and maintainable. It is one of the most important techniques for writing loosely coupled code.

The Problem of Tight Coupling

When a class creates its own dependencies, it is tightly coupled to those concrete implementations. Consider a reporting service that creates its own database connection.

public class ReportService {
    private Database database;

    public ReportService() {
        this.database = new MySQLDatabase("localhost", "user", "password");
    }

    public Report generate() {
        return database.queryReport();
    }
---

This class is difficult to test — you cannot test its logic without a running MySQL database. It is also difficult to change — switching to PostgreSQL requires modifying the constructor. The class violates the Open-Closed Principle because it is not open for extension without modification.

Forms of Dependency Injection

There are three common forms of dependency injection.

Constructor Injection

Constructor injection passes dependencies through the constructor. This is the most common and most recommended form. It makes dependencies explicit and ensures the object is never created in an invalid state.

public class ReportService {
    private final Database database;

    public ReportService(Database database) {
        this.database = database;
    }

    public Report generate() {
        return database.queryReport();
    }
---

The class now accepts any implementation of the Database interface. In production, you pass a MySQLDatabase. In tests, you pass a MockDatabase or an InMemoryDatabase.

Setter Injection

Setter injection provides dependencies through setter methods. It is useful for optional dependencies or when circular dependencies make constructor injection impossible.

public class ReportService {
    private Database database;

    public void setDatabase(Database database) {
        this.database = database;
    }
---

The downside is that the object can be created without its dependencies, leading to null checks scattered throughout the code. The dependencies are also mutable, which can cause issues in multithreaded environments.

Interface Injection

Interface injection defines an interface that the dependency injection framework calls to inject dependencies. This form is rare in modern code. Most frameworks support constructor and setter injection natively.

The Dependency Injection Principle

The dependency injection principle states that classes should depend on abstractions, not concretions. In practice, this means depending on interfaces or abstract classes rather than concrete implementations.

public interface Database {
    Report queryReport();
---

The ReportService depends on the Database interface. The actual implementation is injected from outside. This is a direct application of the Dependency Inversion Principle — the last of the SOLID principles.

Dependency Injection Containers

A dependency injection container — also called an IoC container — automates the wiring of dependencies. You configure the container with mappings from interfaces to implementations, and it constructs the object graph for you.

How Containers Work

In a Spring application, you annotate components with @Service or @Repository and the container discovers them automatically. When you request a ReportService, the container sees that it needs a Database, finds a suitable bean, injects it, and returns the fully constructed object.

@Configuration
public class AppConfig {
    @Bean
    public Database database() {
        return new MySQLDatabase("localhost", "db", "password");
    }

    @Bean
    public ReportService reportService() {
        return new ReportService(database());
    }
---

Containers also manage object lifetimes. They can create a new instance for every request, share a singleton across the application, or keep an instance alive for the duration of an HTTP request.

Testing with Dependency Injection

The primary benefit of dependency injection is testability. When dependencies are injected, you can replace them with test doubles.

class ReportServiceTest {
    @Test
    void shouldGenerateReport() {
        InMemoryDatabase database = new InMemoryDatabase();
        database.addSampleData();
        ReportService service = new ReportService(database);

        Report report = service.generate();

        assertNotNull(report);
        assertEquals("Expected Title", report.getTitle());
    }
---

The InMemoryDatabase implements the same Database interface but stores data in memory. Tests run quickly, require no external infrastructure, and can be run in parallel.

Common Pitfalls

Over-injection is a common mistake. When a class has too many dependencies, it is probably doing too much. This is a violation of the Single Responsibility Principle. Refactor the class into smaller classes.

Service locator is an anti-pattern that disguises itself as dependency injection. Instead of injecting dependencies, the class calls a static locator to get them. Service locator hides dependencies and makes testing difficult.

Constructor injection with too many parameters also indicates a design problem. If a constructor needs more than three or four parameters, consider whether the class has too many responsibilities. Group related dependencies into parameter objects.

DI Containers and Frameworks

Dependency injection containers automate wiring of dependencies. In Python, dependency-injector provides declarative container configuration. In Java, Spring’s @Autowired and Guice are standards. In TypeScript, tsyringe and inversify provide decorator-based DI. Containers can manage singleton, transient, and scoped lifetimes. Use containers for medium-to-large applications — manual DI is sufficient for small projects and avoids framework lock-in.

Testing with DI

DI dramatically improves testability by allowing mock implementations to replace real dependencies. Constructor injection makes dependencies explicit in the type signature, and a simple configuration change swaps implementations. Integration tests use the real container with test-specific modules, while unit tests create objects with mock dependencies manually.

Avoiding the Service Locator Anti-Pattern

A service locator is a global registry that classes call to get their dependencies. It hides what a class needs and makes testing difficult because you cannot control dependencies from the test. DI, by contrast, makes dependencies visible in the constructor signature. If you find yourself using ServiceLocator.get(Database.class), refactor to constructor injection.

Conclusion

Dependency injection is a fundamental technique for writing maintainable software. It promotes loose coupling, enhances testability, and makes dependencies explicit. Combined with a DI container, it reduces boilerplate and lets you focus on business logic. The investment in learning and applying dependency injection pays dividends throughout the life of a project.

FAQ

What is the difference between dependency injection and a service locator? DI passes dependencies into the constructor explicitly. A service locator is a global registry that classes call to get their dependencies. DI makes dependencies visible and testable; a service locator hides them.

When should I use setter injection instead of constructor injection? Use setter injection for optional dependencies (where a default is acceptable) or when circular dependencies prevent constructor injection. Constructor injection is preferred for required dependencies.

Does dependency injection add performance overhead? The overhead of DI is negligible in most applications. Reflection-based container initialization happens once at startup. The real performance cost comes from badly designed abstractions, not from DI itself.

Is dependency injection necessary in functional programming? In functional programming, dependencies are passed as function arguments (currying or partial application). This achieves the same goal — explicit dependencies that can be swapped for testing — without requiring a DI container.

Inversion of Control Containers

An IoC container manages object creation and wiring automatically. In most frameworks, you register interfaces and their implementations with the container, and the container resolves dependencies when objects are constructed:

// ASP.NET Core DI registration
services.AddScoped<IDatabase, PostgresDatabase>();
services.AddTransient<IEmailService, SmtpEmailService>();
services.AddSingleton<ICache, RedisCache>();

Service lifetimes determine when objects are created and destroyed:

  • Transient — Created each time they are requested. Best for lightweight, stateless services.
  • Scoped — Created once per request/transaction. Used for database contexts and request-scoped services.
  • Singleton — Created once and shared across all requests. Best for configuration, caching, and thread-safe services.

Choosing the wrong lifetime is a common source of bugs — scoped dependencies injected into singletons become captive dependencies, holding references that outlive their intended scope.

Property Injection vs Constructor Injection

Constructor injection is the preferred approach — it makes dependencies explicit, ensures objects are created in a valid state, and works well with IoC containers. Property injection is useful when a dependency is optional or when the class is created by a framework that does not support constructor injection (like some serialization frameworks). Method injection passes dependencies as method parameters — useful when the dependency varies per method call.

Anti-Patterns to Avoid

  • Service Locator — A global registry that objects query for dependencies. It obscures dependencies and makes testing difficult because dependencies are not visible in the constructor signature.
  • Control Freak — Manually creating dependencies inside a class (new Database()) instead of receiving them. This couples the class to concrete implementations and prevents substitution.
  • Constructor Over-injection — A constructor with more than 3-4 parameters may indicate the class has too many responsibilities. Consider splitting it into multiple classes.

Testing with Dependency Injection

DI fundamentally improves testability by allowing replacement of real dependencies with test doubles:

// Production
UserService service = new UserService(new PostgresUserRepository(datasource));

// Test with mock
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById("123")).thenReturn(new User("123", "test@example.com"));
UserService service = new UserService(mockRepo);

Manual DI (no container) is the simplest test approach — construct objects with test dependencies in test setup. This works well for small projects but becomes verbose as the dependency graph grows.

Container-assisted testing uses the DI container with a test configuration that replaces real implementations with mocks or in-memory alternatives. Frameworks like Spring provide @MockBean and @TestConfiguration for this purpose.

Ambient Context Anti-Pattern

Avoid static accessors for dependencies (Service Locator, ThreadStatic, Ambient Context). They hide dependencies and make testing difficult because test code must set up global state before each test. Instead, inject dependencies explicitly through constructors. The small amount of extra code in wiring is worth the clarity and testability gained.


Related: Clean Code Guide | SOLID Principles Guide

Section: Software Design 1560 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top