In this blog post, we will explore the fifth and final principle in the SOLID design principles series: the Dependency Inversion Principle (DIP). In case you missed the previous posts in this series, you can catch up here:
Dependency Inversion principle emphasizes the importance of decoupling high-level modules from low-level modules by relying on abstractions rather than concrete implementations.
Dependency Inversion principle
The Dependency Inversion Principle consists of two key concepts:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.
Let me explain what are high-level modules and low-level modules.
High-Level Modules
High-level modules are parts of a program that contain the core business logic or the main functionality of the application. They are responsible for the overall workflow and orchestrating the major processes of the application.
Think of high-level modules as the "brains" of your application. They define what needs to be done but don't necessarily concern themselves with how the lower-level details are executed. They are usually the classes or components that make important decisions or control the flow of the application.
Low-Level Modules
Low-level modules are parts of a program that handle specific, detailed tasks or operations. These modules provide the concrete implementations of various functionalities required by the high-level modules.
Think of low-level modules as the "workers" of your application. They are responsible for the how — the detailed implementation of specific tasks that the high-level modules need to perform.
In traditional design (without DIP), high-level modules might directly depend on low-level modules. This means if you change a low-level module, you might also need to change the high-level module, which creates a tight coupling and makes the system rigid and harder to maintain.
Dependency Inversion Principle suggests that instead of having high-level modules directly dependent on low-level modules, both should depend on interfaces or abstract classes. This approach reduces the coupling between the modules, making the system more flexible and easier to maintain or extend.
Example
Let’s look at a real-world example to understand DIP in the context of a Library Management System.
Let's outline the key requirements for our Library Management System:
Lending Different Types of Books: The system should support lending physical books, Ebooks, and potentially other types of books (e.g., audiobooks) in the future.
Decoupled Components: The system should be designed in a way that the core
Library
class does not depend directly on specific implementations of book lending. This will allow for easier extension and maintenance.Extensibility: The system should allow for the addition of new types of book lending without modifying the existing
Library
class, adhering to the Open/Closed Principle (OCP).Testability: The system should be testable, with the ability to mock different lending behaviors during unit testing.
Initial Implementation (Violating DIP)
Let’s start with an implementation that violates the Dependency Inversion Principle:
public class Library
{
private PhysicalBookLender physicalBookLender = new PhysicalBookLender();
private EBookLender eBookLender = new EBookLender();
public void LendBook(string bookType)
{
if (bookType == "Physical")
{
physicalBookLender.Lend();
}
else if (bookType == "EBook")
{
eBookLender.Lend();
}
}
}
public class PhysicalBookLender
{
public void Lend()
{
Console.WriteLine("Lending a physical book...");
}
}
public class EBookLender
{
public void Lend()
{
Console.WriteLine("Lending an ebook...");
}
}
Issues with the Current Design:
The
Library
class is tightly coupled with thePhysicalBookLender
andEBookLender
classes.Adding new types of books (e.g.,
AudioBookLender
) would require modifying theLibrary
class, violating the Open/Closed Principle (OCP).This tight coupling makes the system rigid, less flexible, and harder to maintain or extend.
Refactoring to Follow DIP
To address these issues, we'll apply the Dependency Inversion Principle by introducing an abstraction that both PhysicalBookLender
and EBookLender
will implement. The Library
class will then depend on this abstraction instead of concrete implementations.
Step 1: Introduce an Abstraction
We start by defining an interface, IBookLender
, which both PhysicalBookLender
and EBookLender
will implement.
// Abstraction
public interface IBookLender
{
void Lend();
}
Step 2: Refactor the Library Class
Now, let's refactor the Library
class to depend on the IBookLender
interface instead of concrete classes:
// High-level module depending on abstraction
public class Library
{
private readonly IBookLender bookLender;
public Library(IBookLender bookLender)
{
this.bookLender = bookLender;
}
public void LendBook()
{
bookLender.Lend();
}
}
Step 3: Implement the Abstraction in Low-Level Modules
The low-level modules PhysicalBookLender
and EBookLender
will implement the IBookLender
interface:
// Low-level module implementing the abstraction
public class PhysicalBookLender : IBookLender
{
public void Lend()
{
Console.WriteLine("Lending a physical book...");
}
}
public class EBookLender : IBookLender
{
public void Lend()
{
Console.WriteLine("Lending an ebook...");
}
}
Injecting Dependencies
With the above changes, you might be wondering how to choose between lending a physical book or an ebook. This decision is handled by injecting the appropriate implementation of IBookLender
into the Library
class. This can be done through dependency injection, a common design pattern in modern C# applications.
Here's an example of how you might use dependency injection:
public class Program
{
public static void Main(string[] args)
{
IBookLender bookLender = new PhysicalBookLender(); // Could also be EBookLender
Library library = new Library(bookLender);
library.LendBook();
}
}
In a more sophisticated application, dependency injection could be managed by an IoC (Inversion of Control) container, such as those provided by ASP.NET Core.
By applying the Dependency Inversion Principle, we gain several benefits:
Reduced Coupling: The
Library
class is decoupled from specific implementations of book lending, allowing easy extension without modifying theLibrary
class.Improved Testability: Since
Library
depends on an interface, it's easier to mockIBookLender
in unit tests, leading to more reliable and isolated tests.Flexibility: Adding new types of book lenders, such as an
AudioBookLender
, requires only the creation of a new class that implementsIBookLender
, without modifying the existingLibrary
class.
Summary
In this post, we explored the Dependency Inversion Principle and how it can help us build more modular, flexible, and maintainable applications. By introducing interfaces or abstract classes to define dependencies between layers, we can decouple dependent layers from each other and make our code easier to modify or extend.