Dependency Inversion Principle#

Twitter Handle LinkedIn Profile GitHub Profile Tag Code

Definition#

In object-oriented design, the dependency inversion principle is a specific methodology for loosely coupling software modules. When following this principle, the conventional dependency relationships established from high-level, policy-setting modules to low-level, dependency modules are reversed, thus rendering high-level modules independent of the low-level module implementation details. The principle states[1]:

  • High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).

  • Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

By dictating that both high-level and low-level objects must depend on the same abstraction, this design principle inverts the way some people may think about object-oriented programming[2].

Low Level and High Level Modules#

Low level modules are “low level” because they have no dependencies, or no relevant dependencies. Very often, they can be easily reused in different contexts without introducing any separate, formal interfaces - which means, reusing them is straightforward, simple and does not require any Dependency Inversion.

High level modules, however, are “high level”, because they require other, lower level modules to work. But if they are tied to a specific low-level implementation, this often prevents to reuse them in a different context.

High level modules depend on low level modules, but shouldn’t depend on their implementation. This can be achieved by using interfaces, thus decoupling the definition of the service from the implementation[3].

A Violation of Dependency Inversion Principle#

Let’s look at a code example that violates the Dependency Inversion Principle.

 1"""Violation of DII."""
 2from typing import Any, Callable, List, Literal, Union
 3
 4from rich.pretty import pprint
 5
 6TransformFunc = Callable[[Any], str]
 7
 8
 9class ImageClassificationTransforms:
10    """Dummy class for image classification transforms."""
11
12    def get_train_transforms(self) -> TransformFunc:
13        """Get train transforms."""
14        return lambda x: f"Using {self.__class__.__name__} for training: {x}"
15
16    def get_test_transforms(self) -> TransformFunc:
17        """Get test transforms."""
18        return lambda x: f"Using {self.__class__.__name__} for testing: {x}"
19
20
21class ImageSegmentationTransforms:
22    """Dummy class for image segmentation transforms."""
23
24    def get_train_transforms(self) -> TransformFunc:
25        """Get train transforms."""
26        return lambda x: f"Using {self.__class__.__name__} for training: {x}"
27
28    def get_test_transforms(self) -> TransformFunc:
29        """Get test transforms."""
30        return lambda x: f"Using {self.__class__.__name__} for testing: {x}"
31
32
33# violates DIP
34class CustomDataset:
35    """Enhanced class for a custom dataset, with a real __getitem__ method."""
36
37    def __init__(self, data: List[Any], stage: Literal["train", "test"] = "train") -> None:
38        self.data: List[Any] = data
39        self.stage: str = stage
40
41        # Directly using ImageClassificationTransforms without interface/abstraction
42        self.transforms: ImageClassificationTransforms = ImageClassificationTransforms()
43
44    def apply_transforms(self, item: Any) -> str:
45        """Apply transforms to a single data item based on stage."""
46        if self.stage == "train":
47            transformed = self.transforms.get_train_transforms()(item)
48        else:
49            transformed = self.transforms.get_test_transforms()(item)
50        return transformed
51
52    def __getitem__(self, index: Union[int, slice]) -> Union[str, List[str]]:
53        """Fetch and transform item(s) from dataset by index."""
54        if isinstance(index, int):  # Single item requested
55            item = self.data[index]
56            return self.apply_transforms(item)
57        elif isinstance(index, slice):  # Slice of items requested
58            items = self.data[index]
59            return [self.apply_transforms(item) for item in items]
60        else:
61            raise TypeError("Invalid index type. Must be int or slice.")
62
63
64if __name__ == "__main__":
65    dummy_data = ["data1", "data2", "data3", "data4", "data5"]
66    dataset_train = CustomDataset(data=dummy_data, stage="train")
67    dataset_test = CustomDataset(data=dummy_data, stage="test")
68
69    # Access single item
70    single_item_train = dataset_train[2]  # Should apply training transform to 'data3'
71    pprint(single_item_train)
72    single_item_test = dataset_test[2]  # Should apply testing transform to 'data3'
73    pprint(single_item_test)
74
75    # Access a slice of items
76    slice_items_train = dataset_train[1:4]  # Should apply training transform to 'data2', 'data3', 'data4'
77    slice_items_test = dataset_test[1:4]  # Should apply testing transform to 'data2', 'data3', 'data4'
78    pprint(slice_items_train)
79    pprint(slice_items_test)
80
81    # you cannot change transforms from ImageClassification to ImageSegmentation
82    # without changing the code in CustomDataset class.
'Using ImageClassificationTransforms for training: data3'
'Using ImageClassificationTransforms for testing: data3'
[
│   'Using ImageClassificationTransforms for training: data2',
│   'Using ImageClassificationTransforms for training: data3',
│   'Using ImageClassificationTransforms for training: data4'
]
[
│   'Using ImageClassificationTransforms for testing: data2',
│   'Using ImageClassificationTransforms for testing: data3',
│   'Using ImageClassificationTransforms for testing: data4'
]

In the provided code example, we encounter a clear violation of the Dependency Inversion Principle (DIP), which affects the design’s flexibility and maintainability. Let’s dissect the relationship and dependency structure between the modules involved:

  • CustomDataset: This is a high-level module designed to manage dataset items and apply specific transformations to these items based on the dataset’s stage (training or testing). Its responsibilities include fetching data items by index and applying the appropriate transformation functions to prepare data for either training or testing scenarios.

  • ImageClassificationTransforms and ImageSegmentationTransforms: These low-level modules are responsible for providing the actual transformation functions applicable to the dataset items. Each module defines two methods, get_train_transforms and get_test_transforms, which return transformation functions tailored to either training or testing.

  • In our code, the high level module depends on the low level module such that the creation of ImageClassificationTransforms is done inside the CustomDataset constructor. This leads to high coupling.

  • Direct Dependency: Within the CustomDataset constructor, an instance of ImageClassificationTransforms is directly instantiated. This direct instantiation tightly couples the CustomDataset class to the ImageClassificationTransforms class. As a result, CustomDataset is not just dependent on the abstraction of transformation functions but on a specific implementation of these functions provided by ImageClassificationTransforms.

  • Coupling and Flexibility Issues: This coupling between the high-level CustomDataset module and the low-level ImageClassificationTransforms module restricts the flexibility of the dataset management system. Should there be a need to apply a different set of transformations (e.g., using ImageSegmentationTransforms for a segmentation task), the current design requires modifying the CustomDataset class itself to change the dependency. This design goes against the DIP’s guidance, which suggests that both high-level and low-level modules should depend on abstractions, not on concrete implementations.

In other words:

  • The code looks fine but if we want to change the ImageClassificationTransforms to ImageSegmentationTransforms, then we have to change the CustomDataset code in two places:

    • Type hint of ImageClassificationTransforms to ImageSegmentationTransforms;

    • Change manually the ImageClassificationTransforms to ImageSegmentationTransforms in the constructor.

  • Things soon get out of hand if we have a lot more of such dependencies, such as ObjectDetectionTransforms, ImageCaptioningTransforms, etc.

Correcting the Violation#

To adhere to the Dependency Inversion Principle, the design should be refactored such that:

  1. Depend on Abstractions: Both CustomDataset and the transformation providing modules (ImageClassificationTransforms, ImageSegmentationTransforms) should depend on a common abstraction (e.g., an interface or a base class) that defines the contract for transformation functions.

  2. Injection of Dependencies: Instead of directly instantiating a specific transformations class within the CustomDataset constructor, the transformation provider should be passed in as a parameter (dependency injection). This approach allows the CustomDataset to remain agnostic of the concrete implementation of transformation functions, enhancing modularity, flexibility, and the ease of testing.

../../_images/uml.drawio.svg

Fig. 7 A very ugly UML diagram.#

More concretely, we can create an interface Transforms that will be implemented by ImageClassificationTransforms, ImageSegmentationTransforms, etc. Then, we can pass the Transforms object to the CustomDataset constructor __init__ method. This way, the CustomDataset will depend on the Transforms interface and not on the ImageClassificationTransforms class. This way, we can change the ImageClassificationTransforms to ImageSegmentationTransforms without changing the CustomDataset code. This is called Dependency Inversion.

The abstraction does not depend on details simply mean the abstract class should not hold any implementation. The implementation should be done in the concrete class.

For example, in my Transforms(ABC) abstract class/interface below, I have two abstract methods get_train_transforms and get_test_transforms. These methods are not implemented in the abstract class. They are implemented in the concrete class ImageClassificationTransforms. This is the second rule in Dependency Inversion Principle.

In the high level module CustomDataset, I have a constructor __init__ that takes in a Transforms abstract class/interface. Now my CustomDataset depends on the Transforms abstraction and not on the ImageClassificationTransforms class. This is the first rule in Dependency Inversion Principle. Furthermore, if you were to switch your task from image classification to image segmentation, you can simply change the ImageClassificationTransforms to ImageSegmentationTransforms without changing the CustomDataset code as you are not creating/coupled to the ImageClassificationTransforms class.

 1"""Less violation but everything is contained in one script, the inversion is not obvious."""
 2from abc import ABC, abstractmethod
 3from typing import Any, Callable, List, Literal, Union
 4
 5from rich.pretty import pprint
 6
 7TransformFunc = Callable[[Any], str]
 8
 9
10class Transforms(ABC):
11    """Abstract class for transforms."""
12
13    @abstractmethod
14    def get_train_transforms(self) -> TransformFunc:
15        """Get train transforms."""
16
17    @abstractmethod
18    def get_test_transforms(self) -> TransformFunc:
19        """Get test transforms."""
20
21
22class ImageClassificationTransforms(Transforms):
23    """Dummy class for image classification transforms."""
24
25    def get_train_transforms(self) -> TransformFunc:
26        """Get train transforms."""
27        return lambda x: f"Using {self.__class__.__name__} for training: {x}"
28
29    def get_test_transforms(self) -> TransformFunc:
30        """Get test transforms."""
31        return lambda x: f"Using {self.__class__.__name__} for testing: {x}"
32
33
34class ImageSegmentationTransforms(Transforms):
35    """Dummy class for image segmentation transforms."""
36
37    def get_train_transforms(self) -> TransformFunc:
38        """Get train transforms."""
39        return lambda x: f"Using {self.__class__.__name__} for training: {x}"
40
41    def get_test_transforms(self) -> TransformFunc:
42        """Get test transforms."""
43        return lambda x: f"Using {self.__class__.__name__} for testing: {x}"
44
45
46class CustomDataset:
47    """Enhanced class for a custom dataset, with a real __getitem__ method."""
48
49    def __init__(self, transforms: Transforms, data: List[Any], stage: Literal["train", "test"] = "train") -> None:
50        self.data: List[Any] = data
51        self.stage: str = stage
52
53        # Directly using ImageClassificationTransforms without interface/abstraction
54        self.transforms = transforms
55
56    def apply_transforms(self, item: Any) -> str:
57        """Apply transforms to a single data item based on stage."""
58        if self.stage == "train":
59            transformed = self.transforms.get_train_transforms()(item)
60        else:
61            transformed = self.transforms.get_test_transforms()(item)
62        return transformed
63
64    def __getitem__(self, index: Union[int, slice]) -> Union[str, List[str]]:
65        """Fetch and transform item(s) from dataset by index."""
66        if isinstance(index, int):  # Single item requested
67            item = self.data[index]
68            return self.apply_transforms(item)
69        elif isinstance(index, slice):  # Slice of items requested
70            items = self.data[index]
71            return [self.apply_transforms(item) for item in items]
72        else:
73            raise TypeError("Invalid index type. Must be int or slice.")
74
75
76if __name__ == "__main__":
77    dummy_data = ["data1", "data2", "data3", "data4", "data5"]
78
79    image_classification_transforms = ImageClassificationTransforms()
80    dataset_train = CustomDataset(transforms=image_classification_transforms, data=dummy_data, stage="train")
81    pprint(dataset_train[0])
82
83    # you can change transforms from ImageClassification to ImageSegmentation
84    image_segmentation_transforms = ImageSegmentationTransforms()
85    dataset_test = CustomDataset(transforms=image_segmentation_transforms, data=dummy_data, stage="test")
86    pprint(dataset_test[0])
'Using ImageClassificationTransforms for training: data1'
'Using ImageSegmentationTransforms for testing: data1'

Originally, CustomDataset creates its own dependency and it is the one controlling the dependency. Now after applying Dependency Inversion Principle, CustomDataset is no longer creating its own dependency. It is now injected with the dependency. This inverts the control of the dependency from CustomDataset to the caller of CustomDataset. This is the Dependency Inversion Principle.

In traditional sense, since class A depends on class B, then class A is the one creating the dependency. But after applying Dependency Inversion Principle, class A is no longer creating the dependency. Instead, the dependency is instantiated outside of class A at runtime and is injected into class A. This is the Dependency Inversion Principle, a form of Inversion of Control.

Dependency Inversion Principle and Dependency Injection#

The Dependency Inversion Principle (DIP) is just one part of the larger concept of Dependency Injection (DI). While DIP is about the static structure of your code (i.e., how classes and modules are related to each other), DI is about how the dependencies are provided to an object at runtime.

DIP is primarily concerned with the design and static structure of code, guiding developers on how to write modules that remain as independent as possible from the implementations of the modules they rely on.

DI, on the other hand, is a design pattern that implements the Dependency Inversion Principle. It’s about the actual mechanics of providing an object with the outside resources (dependencies) it needs to perform its functions.

In summary, DIP advocates for a particular structure of code relationships to reduce the coupling between high-level and low-level modules, while DI is a practical way to achieve this by handling the creation and binding of dependencies externally. Adhering to DIP will allow your software to become more modular, easier to test, and more maintainable.

References and Further Readings#