1729 words
9 minutes
Understanding Dependency Injection in Python with Real Examples

Understanding Dependency Injection in Python#

Dependency Injection (DI) is a design pattern that helps manage how components within a software system rely on each other. It addresses the problem of tight coupling between objects by shifting the responsibility of creating or locating dependencies from the dependent object itself to an external entity.

Dependencies are objects that a class needs to perform its function. For example, a UserService might depend on a Database object to store and retrieve user data. Without Dependency Injection, the UserService class would typically be responsible for creating an instance of the Database class within its own code (e.g., in its constructor). This creates a tight coupling: the UserService is directly tied to a specific Database implementation.

Dependency Injection promotes Inversion of Control (IoC). Instead of the dependent object controlling the creation of its dependencies, the control is inverted, and the dependencies are provided to the object from the outside.

Core Concepts in Dependency Injection#

Understanding DI requires grasping a few related concepts:

  • Dependency: Any object that another object needs to execute its methods or function correctly. In the UserService example, the Database is a dependency of the UserService.
  • Dependent: The object that requires a dependency. The UserService is the dependent object.
  • Injection: The process of providing a dependency to the dependent object from an external source. This can happen via the constructor, setter methods, or interface methods.
  • Inversion of Control (IoC): A principle where the flow of control of a system is inverted compared to traditional procedural programming. In DI, IoC means the container or framework dictates when and how dependencies are created and injected, rather than the dependent object controlling its own dependency creation. DI is a specific technique used to achieve IoC.
  • Container/Injector: An external entity (often a framework or manually implemented logic) responsible for managing the lifecycle of objects and injecting dependencies. It creates objects and provides them with the dependencies they need.

Why Implement Dependency Injection in Python?#

Implementing Dependency Injection principles in Python development offers significant advantages:

  1. Improved Testability: Dependencies can be easily replaced with test doubles (like mocks or stubs) during unit testing. If UserService creates its Database internally, testing UserService requires a real database or complex patching. With DI, a MockDatabase can be injected, isolating the unit test to the UserService logic itself.
  2. Increased Flexibility and Reusability: Components become less coupled to specific implementations. Different implementations of a dependency (e.g., PostgresDatabase vs. MongoDatabase) can be swapped out without modifying the dependent class (UserService), as long as they adhere to a common interface or structure.
  3. Easier Maintenance and Refactoring: Changes to a dependency’s implementation or configuration do not require modifying the dependent class, reducing the risk of introducing bugs in unrelated parts of the code. Refactoring becomes simpler as responsibilities are clearer.
  4. Better Separation of Concerns: Each class focuses on its primary responsibility (UserService managing users) rather than the secondary responsibility of managing its dependencies (UserService deciding how to instantiate a Database).

Implementing Dependency Injection Manually in Python#

While DI frameworks exist, understanding manual Dependency Injection is crucial as it illustrates the core principle clearly. The most common form of injection in Python is Constructor Injection.

Here’s a basic example:

Without Dependency Injection (Tight Coupling):

class Database:
def __init__(self):
print("Database connection established.")
# Simulate complex connection logic
def save(self, data):
print(f"Saving data: {data}")
class UserService:
def __init__(self):
# UserService is responsible for creating its dependency
self.db = Database() # Tight coupling to the Database class
def create_user(self, user_data):
# Use the internal dependency
self.db.save(user_data)
# Usage
# service = UserService()
# service.create_user({"name": "Alice"})

In this scenario, testing UserService.create_user would implicitly require a real Database instance and its connection logic. Mocking the self.db inside UserService after instantiation is possible but less clean than injecting.

With Dependency Injection (Constructor Injection):

class Database:
def __init__(self):
print("Database connection established.")
# Simulate complex connection logic
def save(self, data):
print(f"Saving data: {data}")
class MockDatabase:
def save(self, data):
print(f"Mock saving data: {data}") # Simulate saving without actual DB
class UserService:
# UserService receives its dependency via the constructor
def __init__(self, db_dependency):
self.db = db_dependency # Dependency is injected
def create_user(self, user_data):
# Use the injected dependency
self.db.save(user_data)
# Usage in a "production-like" scenario
print("--- Production Usage ---")
real_db = Database()
production_service = UserService(real_db) # Inject the real database
production_service.create_user({"name": "Bob"})
print("\n--- Testing Usage ---")
mock_db = MockDatabase()
test_service = UserService(mock_db) # Inject the mock database
test_service.create_user({"name": "Charlie"}) # Test uses mock, no real DB needed

In the DI example, the UserService no longer creates the Database instance. It receives an instance via its __init__ method. This makes the UserService dependent on any object that has a save method (or adheres to a specific interface/protocol), rather than a specific Database class. This is significantly more flexible and testable.

Other Injection Types#

While Constructor Injection is the most prevalent in Python, other types exist:

  • Setter Injection: The dependency is provided through a setter method after the object has been constructed.
    class UserService:
    def __init__(self):
    self.db = None # Dependency starts as None
    def set_database(self, db_dependency):
    self.db = db_dependency # Dependency injected via setter
    def create_user(self, user_data):
    if self.db:
    self.db.save(user_data)
    else:
    print("Error: Database dependency not set!")
    This can be useful for optional dependencies or when dependencies create circular references, but it makes the object unusable until the setter is called and introduces the risk of using the object before the dependency is set.
  • Method Injection: The dependency is passed directly into the method that needs it.
    class UserService:
    def create_user(self, user_data, db_dependency): # Dependency passed to method
    db_dependency.save(user_data)
    This is suitable when only a single method needs a specific dependency, preventing the entire object from needing it. However, it can lead to repetitive passing of the same dependency to multiple method calls.

Constructor Injection is generally favored because it ensures the object is created in a fully initialized state with all its necessary dependencies, making its requirements explicit.

Managing Dependencies with a Simple Factory/Container#

As an application grows, manually creating and injecting dependencies everywhere becomes cumbersome. This is where a simple factory or the concept of a Dependency Injection Container becomes useful. A container’s job is to know how to create objects and their dependencies and provide the fully wired-up objects when requested.

Here’s a very basic manual factory approach:

# Define components
class Database:
def __init__(self, connection_string):
self.connection_string = connection_string
print(f"Connecting to DB: {self.connection_string}")
def save(self, data):
print(f"Saving via {self.connection_string}: {data}")
class UserService:
def __init__(self, db_dependency):
self.db = db_dependency
def create_user(self, user_data):
self.db.save(user_data)
# Simple Factory/Assembler
class AppFactory:
def __init__(self, db_connection_string):
self.db_connection_string = db_connection_string
self._db_instance = None # Cache DB instance (optional, for singleton behavior)
def get_database(self):
if not self._db_instance: # Implement simple singleton logic
print("Creating new Database instance")
self._db_instance = Database(self.db_connection_string)
return self._db_instance
def get_user_service(self):
# The factory knows how to build UserService and inject its dependency
db = self.get_database()
return UserService(db)
# Usage
print("\n--- Factory Usage ---")
# The entry point (or main function) uses the factory
factory = AppFactory("postgresql://user:pass@host:port/dbname")
# Request the high-level service; the factory handles dependency creation/injection
user_service_instance = factory.get_user_service()
user_service_instance.create_user({"id": 1, "name": "David"})
# Requesting again might return the same DB instance depending on factory logic
another_user_service_instance = factory.get_user_service()
another_user_service_instance.create_user({"id": 2, "name": "Eve"})

This manual factory centralizes the creation and wiring of objects. When get_user_service is called, the factory first ensures it has a Database instance (potentially reusing one, acting like a simple singleton scope), then creates a UserService and injects the Database instance into it. This separates the object creation logic from the object’s business logic.

Real-world DI containers like python-dependency-injector automate this process, allowing registration of classes and configuration of how they should be instantiated and wired together, managing complex dependency graphs automatically.

Real-World Application: Database Access Layer#

Consider a web application backend that interacts with a database. Using DI allows the application to switch between different database systems (e.g., SQLAlchemy ORM, a raw SQL client, or a NoSQL client) or use mock databases for testing without changing the core business logic classes.

# Define interfaces (using Protocol for type hinting, but conceptually)
from typing import Protocol
class DBClient(Protocol):
def query(self, sql: str) -> list:
...
def execute(self, sql: str, params: dict):
...
# Specific implementations
class SQLAlchemyClient: # Implements DBClient protocol
def __init__(self, engine):
self._engine = engine
print("SQLAlchemy client initialized.")
def query(self, sql: str) -> list:
# SQLAlchemy specific query logic
print(f"SQLAlchemy query: {sql}")
return [{"result": "data"}] # Simulate result
def execute(self, sql: str, params: dict):
# SQLAlchemy specific execution logic
print(f"SQLAlchemy execute: {sql} with {params}")
class MockDBClient: # Implements DBClient protocol
def query(self, sql: str) -> list:
print(f"Mock DB query: {sql}")
return [{"mock_result": "test_data"}] # Return mock data
def execute(self, sql: str, params: dict):
print(f"Mock DB execute: {sql} with {params}")
# Service layer that depends on the DBClient abstraction
class ProductService:
def __init__(self, db_client: DBClient): # Depends on the abstraction
self.db = db_client
def get_product(self, product_id: int):
sql = f"SELECT * FROM products WHERE id = {product_id}"
return self.db.query(sql)
def update_product_stock(self, product_id: int, new_stock: int):
sql = "UPDATE products SET stock = :stock WHERE id = :id"
params = {"stock": new_stock, "id": product_id}
self.db.execute(sql, params)
# Usage Example:
# In a real application entry point or a DI container setup:
# --- Production Configuration ---
# from sqlalchemy import create_engine
# db_engine = create_engine("postgresql://...") # Assume actual engine setup
# prod_db_client = SQLAlchemyClient(db_engine)
# production_product_service = ProductService(prod_db_client)
# print("\n--- Production Usage ---")
# production_product_service.get_product(101)
# production_product_service.update_product_stock(101, 50)
# --- Testing Configuration ---
print("\n--- Testing Usage ---")
mock_db_client = MockDBClient()
test_product_service = ProductService(mock_db_client) # Inject the mock
# Testing get_product without hitting a real database
test_product_service.get_product(202)
# Testing update_product_stock logic
test_product_service.update_product_stock(202, 0)

This example demonstrates how ProductService depends on the abstraction DBClient, not a concrete SQLAlchemyClient. This dependency is injected. In a production environment, a SQLAlchemyClient implementing DBClient is injected. In a test environment, a MockDBClient implementing the same DBClient abstraction is injected. This pattern is fundamental for building maintainable and testable applications.

Summary of Key Takeaways#

  • Dependency Injection (DI) is a design pattern where objects receive their dependencies from an external source rather than creating them internally.
  • DI is a way to implement the Inversion of Control (IoC) principle.
  • Key benefits of DI in Python include improved testability, increased flexibility, easier maintenance, and better separation of concerns.
  • Constructor Injection is the most common DI method in Python, where dependencies are passed into the __init__ method.
  • Setter Injection and Method Injection are alternative approaches for specific scenarios.
  • A simple factory or a DI container helps manage the creation and injection of dependencies in larger applications.
  • Implementing DI often involves programming to interfaces or abstractions (like protocols in Python) rather than concrete implementations.
  • Real-world applications leverage DI to swap out components like database clients or external service connectors for different environments (production, testing).
Understanding Dependency Injection in Python with Real Examples
https://dev-resources.site/posts/understanding-dependency-injection-in-python-with-real-examples/
Author
Dev-Resources
Published at
2025-06-29
License
CC BY-NC-SA 4.0