Understanding Dependency Inversion Principle (DIP) with C#

In this article, 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 articles in this series, you can catch up here.

The 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.

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. 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

  1. 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.
  2. 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

  1. 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.
  2. 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.
  3. 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.
  4. The 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.

  1. 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.
  2. 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.
  3. 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).
  4. 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 the PhysicalBookLender and EBookLender classes.
  • Adding new types of books (e.g., AudioBookLender) would require modifying the Library 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 the Library class.
  • Improved Testability: Since the Library depends on an interface, it's easier to mock IBookLender 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 implements IBookLender, without modifying the existing Library class.

Summary

In this article, 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.


Similar Articles