Dependency Inversion Principle#
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
andImageSegmentationTransforms
: 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
andget_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 theCustomDataset
constructor. This leads to high coupling.Direct Dependency: Within the
CustomDataset
constructor, an instance ofImageClassificationTransforms
is directly instantiated. This direct instantiation tightly couples theCustomDataset
class to theImageClassificationTransforms
class. As a result,CustomDataset
is not just dependent on the abstraction of transformation functions but on a specific implementation of these functions provided byImageClassificationTransforms
.Coupling and Flexibility Issues: This coupling between the high-level
CustomDataset
module and the low-levelImageClassificationTransforms
module restricts the flexibility of the dataset management system. Should there be a need to apply a different set of transformations (e.g., usingImageSegmentationTransforms
for a segmentation task), the current design requires modifying theCustomDataset
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
toImageSegmentationTransforms
, then we have to change theCustomDataset
code in two places:Type hint of
ImageClassificationTransforms
toImageSegmentationTransforms
;Change manually the
ImageClassificationTransforms
toImageSegmentationTransforms
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:
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.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 theCustomDataset
to remain agnostic of the concrete implementation of transformation functions, enhancing modularity, flexibility, and the ease of testing.
Fig. 56 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.