Dependency Injection Pattern — Loosely Coupled Dependencies
What You'll Learn
You will learn how Dependency Injection (DI) inverts the responsibility of creating dependencies, making your classes testable, configurable, and decoupled from their concrete collaborators. You will understand constructor injection, setter injection, and the role of DI containers.
Why It Matters
A UserService that directly instantiates a MySQLDatabase is impossible to unit test without a running MySQL instance. By injecting the database dependency, you can swap in a mock or in-memory database during tests. DI is the foundation of most modern application frameworks. The key insight is that a class should not create its own dependencies — they should be provided from the outside, so the class focuses on behaviour, not construction.
Real-World Use
Spring Framework, Guice, and Dagger are entire ecosystems built around DI. DodaTech's module system auto-wires service dependencies at startup, reading bindings from a configuration file so modules never import concrete implementations directly. When testing, the same configuration file can substitute mock implementations, enabling fast, isolated unit tests.
The Pattern
Client depends on an abstraction. Injector (or DI container) creates the concrete dependency and passes it to the client, usually through constructor injection, setter injection, or interface injection.
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def save(self, data: dict) -> None:
pass
@abstractmethod
def find(self, key: str) -> dict:
pass
class InMemoryDatabase(Database):
def __init__(self):
self._store = {}
def save(self, data: dict) -> None:
self._store[data["id"]] = data
def find(self, key: str) -> dict:
return self._store.get(key, {})
class Logger(ABC):
@abstractmethod
def log(self, message: str) -> None:
pass
class ConsoleLogger(Logger):
def log(self, message: str) -> None:
print(f"[LOG] {message}")
class UserService:
def __init__(self, db: Database, logger: Logger):
self._db = db
self._logger = logger
def create_user(self, user_id: str, name: str):
self._db.save({"id": user_id, "name": name})
self._logger.log(f"User {user_id} created")
def get_user(self, user_id: str) -> dict:
return self._db.find(user_id)
class Injector:
@staticmethod
def create_user_service() -> UserService:
db = InMemoryDatabase()
logger = ConsoleLogger()
return UserService(db, logger)
service = Injector.create_user_service()
service.create_user("1", "Alice")
user = service.get_user("1")
print(f"Found: {user}")
[LOG] User 1 created
Found: {'id': '1', 'name': 'Alice'}
Structure
classDiagram
class Client {
-dependency: Abstraction
+Client(dependency: Abstraction)
}
class Abstraction {
<>
+operation()
}
class ConcreteImplementation {
+operation()
}
class Injector {
+create(): Client
}
Client --> Abstraction : depends on
ConcreteImplementation ..|> Abstraction
Injector --> ConcreteImplementation : creates
Injector --> Client : injects
Real-World Usage
- Spring Framework —
@Autowiredannotation injects beans by type or name; the IoC container manages the full dependency graph. - Angular — component constructors receive injected services; the injector hierarchy supports scoped and Singleton providers.
- FastAPI / Flask — request-scoped dependencies are injected into route handlers.
- Dagger — compile-time DI for Android and Java with zero runtime Reflection overhead.
Related Patterns
- Singleton is often used with DI — injected dependencies are frequently singletons within a scope.
- Factory Method is an alternative to DI for creating dependencies.
- Strategy is commonly injected into clients.
- Service Locator is an alternative pattern for dependency lookup (often considered a DI anti-pattern).
Pros and Cons
| Pros | Cons |
|---|---|
| Decouples interface from implementation | Increases configuration complexity |
| Greatly improves testability | DI container can hide the dependency graph |
| Follows the Dependency Inversion Principle | Over-injection leads to unclear boundaries |
| Centralised wiring reduces duplication | Compile-time verification is harder with runtime DI |
| Encourages small, focused interfaces | Can introduce performance overhead with Reflection-based containers |
The code shows constructor injection — UserService receives its Database and Logger dependencies through its constructor. The Injector class acts as a simple DI container, creating the concrete implementations and wiring them together. For testing, you could create a MockLogger and pass it to UserService without changing any service code.
Practice Questions
- Implement a simple DI container that resolves constructor dependencies recursively.
- Compare constructor injection with setter injection — what are the trade-offs for mandatory vs optional dependencies?
- How would you implement a DI container with scoped lifetimes (transient, Singleton, request-scope)?
- What issues arise when you have circular dependencies in a DI graph, and how would you resolve them?
Challenge
Implement a DI container that supports both transient and Singleton lifetimes. Transient dependencies create a new instance every time; Singleton dependencies reuse the same instance for the lifetime of the container.
Real-World Task
Run DodaTech's dependency graph visualiser on your application. Identify classes that directly instantiate their dependencies (using new or constructors). Refactor at least one to use constructor injection and measure the improvement in testability.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro