Skip to content

SOLID: Dependency Inversion Principle

Published: at 03:20 PM

The Dependency Inversion Principle (DIP) is one of the key principles in SOLID. The principle can be summarized as follows:

  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.

Understanding DIP Through an Example

Consider the following example:

internal class Cog
{
    int CogSize;
}

internal class Engine
{
    Cog cog;
}

class Train
{
    Engine engine;
}

In this example, the Train class is directly dependent on the Engine class, and Engine is dependent on the Cog class. This creates a tightly coupled system, where changes to a low-level class, like Cog, could force changes in the higher-level Engine class, and subsequently, the Train class. This dependency chain violates the first rule of DIP because high-level modules (Train) are dependent on low-level modules (Cog and Engine).

For instance, if Cog changes from having a CogSize property to having CogRadius and TeethSize properties instead, Engine would need to be refactored to accommodate these changes. This ripple effect is a clear sign of tight coupling.

Decoupling with Interfaces

To adhere to the DIP, we can introduce interfaces that decouple these classes:

internal interface ICog
{
    int CogSize { get; }
}

internal class Cog : ICog
{
    public int CogSize { get; set; }
}

internal interface IEngine
{
    ICog Cog { get; }
}

internal class Engine : IEngine
{
    public ICog Cog { get; set; }
}

class Train
{
    IEngine engine;

    public Train(IEngine engine)
    {
        this.engine = engine;
    }
}

By introducing interfaces like ICog and IEngine, we’ve decoupled the Train class from the specific implementations of Engine and Cog. Now, Train depends on the IEngine abstraction, and Engine depends on the ICog abstraction. This means that changes in the Cog class (like replacing CogSize with other properties) will not affect the Engine class as long as the interface ICog remains unchanged.

What is Dependency Inversion?

Dependency Inversion is about reversing the direction of dependency so that high-level modules do not directly depend on low-level modules, but rather both depend on abstractions. In practice, this often means using interfaces or abstract classes to define the contracts that different components of your application must adhere to.

Types of Dependencies

In C#, dependencies typically fall into two categories:

DIP primarily concerns itself with compile-time dependencies, ensuring that they point away from low-level, infrastructure-related code and towards high-level abstractions and business logic.

High-Level vs. Low-Level Modules

Understanding what constitutes high-level and low-level modules is crucial:

The goal of DIP is to keep high-level modules focused on business logic and problem-solving, while low-level modules handle technical details like data persistence or communication with external systems.

What Are Abstractions?

In C#, abstractions often come in the form of interfaces or abstract base classes. These are contracts that define what operations a class should support, without dictating how these operations should be implemented. Abstractions are the glue that allows high-level modules to remain unaware of the specifics of the low-level modules they interact with.

What Are Details?

Details are the specific implementations that fulfill the contract defined by an abstraction. For example, an abstraction might define an operation like “send a message,” while the detail might specify sending an SMTP email over a particular port. The abstraction doesn’t care how the task is completed, only that it gets done.

Common Violations of DIP

Consider the following interface:

public interface IOrderDataAccess
{
    SqlDataReader ListOrders(SqlParameterCollection params);
}

At first glance, this might seem like a reasonable abstraction. However, this interface violates DIP because it exposes low-level details (SqlDataReader and SqlParameterCollection) that tie it to a specific database technology. This not only reduces reusability but also forces any client using this interface to depend on the SQL-related types.

A better approach would be:

public interface IOrderAccess
{
    List<Order> ListOrders(Dictionary<string, string> params);
}

This version abstracts away the details of data access, making the interface more flexible and reusable. Now, the implementation of IOrderAccess can use any data access method without affecting the client code.

Hidden Direct Dependencies

Direct dependencies on low-level components often manifest as static calls or the use of the new keyword to create instances of low-level types within high-level modules. These hidden dependencies lead to tight coupling and make the code harder to test and maintain.

A common phrase you’ll hear in the context of DIP is “New is Glue.” This means that using new to instantiate a dependency glues your code to a specific implementation, making it harder to change later. While new isn’t inherently bad, it should be used with caution. Consider whether you could depend on an abstraction instead, passing in the dependency via constructor injection or another method.

Explicit Dependencies Principle

This principle states that classes should explicitly declare their dependencies through their constructor. This approach eliminates surprises, as clients can clearly see what dependencies a class requires.

The Impact of Abstraction on Dependencies

Without abstractions, the dependency graph of a program might look like this:

compile-time
references
references
class Y
class X
Class Z
run-time
calls
calls
class Y
class X
class Z

At compile-time, class X directly depends on class Y, and class Y depends on class Z. This creates a tightly coupled system. However, by introducing abstractions:

compile-time
references
references
references
references
interface Y
class X
Y
interface Z
class Z
run-time
calls
calls
interface Y

class Y
class X
interface Z

class Z

We see that class X now depends on interface Y, and class Y depends on interface Z. The high-level modules (class X) are no longer directly coupled to low-level modules (class Z), resulting in a more flexible and testable architecture.

Dependency Injection

DIP is often implemented using Dependency Injection (DI), a technique where dependencies are provided to a class, rather than the class creating its own dependencies. DI is an implementation of the Strategy design pattern and is commonly used in modern software development.

Constructor Dependency Injection

Constructor injection is the preferred method of DI, as it makes dependencies explicit and ensures that a class is always fully initialized. It also aligns well with the use of IoC (Inversion of Control) containers, which can resolve dependencies automatically at runtime.

In ASP.NET Core, for example, the built-in service collection acts as a DI container, but you can also use third-party containers if needed.

Example of Refactoring for DIP

Consider the following class:

public class CalculationEngine
{
    public ICalculationContext Context { get; set; } = new DefaultCalculationContext();
    
    public decimal Solution { get; set; }

    public CalculationEngine()
    {
        Context.Engine = this;
    }

    public void Calculate()
    {
        Context.Log("Starting calculation");

        Context.Log("Loading problem");

        string problemJson = Context.LoadPolicyFromFile();

        var problem = Context.GetProblemFromJsonString(problemJson);

        var solution = Context.CalculateSolutionForProblem(problem, Context);

        solution.Calculate(problem);

        // More code
    }
}

In this code, CalculationEngine is tightly coupled to ICalculationContext and its concrete implementation DefaultCalculationContext. This coupling can be problematic.

To refactor this code to follow DIP:

  1. Define abstractions (ICalculationContext) that are not tied to specific implementations.
  2. Inject dependencies through the constructor.
  3. Ensure that your high-level logic in CalculationEngine is isolated from the low-level details in DefaultCalculationContext.

This makes the CalculationEngine more flexible, testable, and easier to maintain.

Key Takeaways