Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 11

Constructor Injection in Python

Recommended resources

Introduction

In our last lesson, we refactored a circular dependency using the Dependency Inversion Principle (DIP). We ended by defining an abstract BookRepository interface, which allowed our high-level BookService to be independent of any specific database implementation. This raised a crucial question: if the BookService no longer creates its own database object, how does it receive the concrete implementation it needs to function?

Today, we will answer that question by exploring Dependency Injection (DI). This lesson focuses on implementing constructor-based dependency injection in Python, the most common and explicit method for applying this pattern. You will learn how to "wire up" your components, providing concrete implementations to classes that depend on abstractions. This technique is the practical engine that powers the Dependency Inversion Principle, turning our decoupled design into a functional application. Mastering DI is a fundamental step toward building the maintainable, testable, and flexible software you aim to create.


What is Dependency Injection?

At its core, Dependency Injection is a design pattern that implements the principle of Inversion of Control (IoC). Instead of an object creating its own dependencies internally, those dependencies are "injected" into the object from an external source.

This simple shift has profound architectural implications. It decouples the object from the responsibility of creating, and thus knowing about, its concrete dependencies.

The key actors in this pattern are:

  • Client: The object that depends on a service to do its work (e.g., our BookService from the previous lesson).
  • Service: The object that the client depends on (e.g., a DatabaseHandler that implements the BookRepository interface).
  • Injector: The code responsible for creating the service (the dependency) and passing it to the client (the dependent).
This diagram shows the relationship between the three core components of Dependency Injection. The Injector creates the Service (the dependency) and injects it into the Client (the dependent). The Client is now decoupled from the creation of its Service.

This diagram shows the relationship between the three core components of Dependency Injection. The Injector creates the Service (the dependency) and injects it into the Client (the dependent). The Client is now decoupled from the creation of its Service.

This approach breaks the hardcoded link between a component and its dependencies, significantly reducing coupling.

Dependency injection and inversion of control in Python

This article from the Dependency Injector documentation provides an excellent "before and after" example that clearly demonstrates the shift from hardcoded dependencies to injected ones.

Please read the introductory section, focusing on the definitions of coupling and cohesion and studying the code transformation from the Before and After blocks. Notice how the responsibility for creating ApiClient moves out of Service.

As the article shows, by moving object creation out of the classes that use them, we gain immense flexibility. The Service class is no longer tied to a specific ApiClient implementation, making the system easier to test, configure, and maintain.


Implementing Constructor Injection in Python

The most direct way to implement Dependency Injection in Python is via constructor injection: passing dependencies as arguments to a class's __init__ method. This makes dependencies explicit and ensures an object cannot be created in an invalid state (i.e., without its required collaborators).

A great way to understand this is to walk through a refactoring process. The following video from the ArjanCodes channel demonstrates this by transforming a tightly-coupled data pipeline into a flexible, decoupled system using DI.

Stop Hardcoding Everything: Use Dependency Injection

This video provides a practical, step-by-step guide to refactoring a hardcoded Python class to use constructor injection.

First, watch the introduction to understand the problem with the initial, hardcoded DataPipeline class at this timestamp. Next, see how the code is refactored to use classes and constructor injection. Pay close attention to how the main function becomes the "injector" or "assembler" in this segment. Finally, and most importantly, watch the section where Protocol is introduced to define abstractions for the dependencies. This connects DI with the Dependency Inversion Principle, which is the key to true decoupling at this timestamp.

As the video demonstrates, the process involves three key steps:

  1. Identify and extract responsibilities: The hardcoded logic for loading, transforming, and exporting data is moved into separate classes.
  2. Inject dependencies via the constructor: The DataPipeline class is modified to accept instances of the loader, transformer, and exporter in its __init__ method instead of creating them itself.
  3. Depend on abstractions: To achieve true decoupling, Protocols are introduced to define interfaces (DataLoader, Transformer, Exporter). The DataPipeline now depends on these abstract protocols, not the concrete classes.

This final step is what makes the pattern so powerful. The DataPipeline is now completely ignorant of how data is loaded or exported; it only knows that it has objects that fulfill the contracts defined by the protocols.

Let's look at another clear example that closely resembles the BookService from our previous lesson.

Dependency Injection: a Python Way - Rost Glukhov

This article by Rost Glukhov provides another concise code example of constructor injection using protocols, which reinforces the pattern we just saw in the video.

Please read the section on constructor injection. Notice how the UserService class depends on the UserRepository protocol, making its dependency explicit and abstract.

The UML diagram below visualizes this exact structure. The CoffeeApp (our Client) depends on the CoffeeMachine interface (our Protocol). It is "injected" with a concrete implementation, BasicCoffeeMachine (our Service), via its constructor.

This UML diagram illustrates constructor-based dependency injection. The `CoffeeApp` has a constructor that accepts an object implementing the `CoffeeMachine` interface. This decouples `CoffeeApp` from the concrete `BasicCoffeeMachine` class.

This UML diagram illustrates constructor-based dependency injection. The CoffeeApp has a constructor that accepts an object implementing the CoffeeMachine interface. This decouples CoffeeApp from the concrete BasicCoffeeMachine class.


The Composition Root: Where Dependencies Are Assembled

We've established that components shouldn't create their own dependencies. This begs the question: where does the creation and "wiring" of objects happen?

The answer is in a single, dedicated location at the very start of the application, a pattern known as the Composition Root. This is typically in your main.py file, an app.py, or a factory function that boots the application.

By centralizing this logic, you make the entire dependency graph of your application explicit and manageable in one place.

Dependency Injection: a Python Way - Rost Glukhov

This section of the same article illustrates how to structure a Composition Root.

Read the section on the Composition Root. Observe how all the concrete objects—repositories, services, and handlers—are instantiated and wired together in the create_app function before the application is returned.

The main function in the ArjanCodes video serves as a simple Composition Root. For a complex application, this assembly logic can become quite intricate, which is where DI frameworks (which we will not cover in detail now) can sometimes be helpful. For most cases, manual wiring in a Composition Root is clear, explicit, and sufficient.


Application to Computer Vision Pipelines

Given your background in computer vision, you can see how this pattern is directly applicable to image processing pipelines. The DataPipeline example is conceptually identical to many CV workflows.

Imagine an ImageProcessingPipeline that needs to:

  1. Load an image.
  2. Run a feature detection algorithm.
  3. Save the results.

Without DI, you might hardcode cv2.imread, a specific cv2.SIFT_create() instance, and a function to write to a JSON file directly inside your pipeline class. This makes it difficult to switch to loading images from a network stream, trying a different feature detector like ORB, or saving results to a database.

With DI, your pipeline's constructor would look like this:

from typing import Protocol, List
import numpy as np

# Define Abstractions (Protocols)
class ImageLoader(Protocol):
    def load(self, source: str) -> np.ndarray: ...

class FeatureExtractor(Protocol):
    def extract(self, image: np.ndarray) -> List[dict]: ...

class ResultSaver(Protocol):
    def save(self, features: List[dict]) -> None: ...

# The pipeline depends only on abstractions
class ImageProcessingPipeline:
    def __init__(
        self, 
        loader: ImageLoader, 
        extractor: FeatureExtractor, 
        saver: ResultSaver
    ):
        self.loader = loader
        self.extractor = extractor
        self.saver = saver

    def process(self, image_path: str):
        print(f"Processing {image_path}...")
        image = self.loader.load(image_path)
        features = self.extractor.extract(image)
        self.saver.save(features)
        print("Processing complete.")

# --- Composition Root (e.g., in main.py) ---

# Concrete Implementations
class FileSystemLoader:
    def load(self, source: str) -> np.ndarray:
        # implementation using OpenCV, Pillow, etc.
        print(f"Loading image from {source}")
        # dummy array for example
        return np.zeros((100, 100, 3), dtype=np.uint8)

class SiftFeatureExtractor:
    def extract(self, image: np.ndarray) -> List[dict]:
        print("Extracting features with SIFT")
        return [{"x": 10, "y": 20, "size": 5}]

class JsonFileSaver:
    def __init__(self, output_path: str):
        self.output_path = output_path
    
    def save(self, features: List[dict]) -> None:
        print(f"Saving features to {self.output_path}")

# Assembling the pipeline
if __name__ == "__main__":
    pipeline = ImageProcessingPipeline(
        loader=FileSystemLoader(),
        extractor=SiftFeatureExtractor(),
        saver=JsonFileSaver("output/features.json")
    )
    
    pipeline.process("data/image1.jpg")

This design allows you to easily swap implementations—for example, replacing SiftFeatureExtractor with a SurfFeatureExtractor or TensorFlowModelExtractor—without touching the ImageProcessingPipeline class itself. This flexibility is invaluable for experimentation and maintenance.


The Payoff: Testability

One of the most significant benefits of DI, and a key element of professional software development, is the dramatic improvement in testability.

Because components depend on abstractions, you can inject "fake" or "mock" implementations during testing. This allows you to test a component's logic in complete isolation, without needing a real database, file system, or network connection. Your tests become faster, more reliable, and more focused.

Dependency Injection: a Python Way - Rost Glukhov

This final section of the article demonstrates how to leverage DI for effective unit testing.

Please review the section on testing. Pay attention to how a MockUserRepository is created and injected into the UserService to test its logic without a real database. The pytest fixture example shows an even cleaner way to manage this.


Conclusion

In this lesson, we have connected the Dependency Inversion Principle to its practical implementation, Dependency Injection. By moving the responsibility of dependency creation out of your components and into a centralized Composition Root, you create a system that is loosely coupled, flexible, and highly testable.

Key Takeaways:

  • Dependency Injection (DI) is a pattern where an object's dependencies are provided from an external source rather than created internally.
  • Constructor Injection is the most explicit and common way to implement DI in Python, by passing dependencies to the __init__ method.
  • For maximum benefit, DI should be used with the Dependency Inversion Principle: components should depend on abstractions (like Python's Protocols), not concrete implementations.
  • The Composition Root is the single place in your application (e.g., main.py) where the object graph is assembled and dependencies are wired together.
  • The primary architectural benefits of DI are loose coupling, which improves maintainability, and the ability to easily substitute implementations, which dramatically improves testability.

The principles we've covered today are not unique to Python. In our next lesson, we will explore how to apply the very same concepts in Rust. You will see how Rust's trait system serves the same purpose as Python's Protocols for defining the abstract dependencies that make this powerful pattern work.