The Dependency Inversion Principle (DIP) is one of the key principles in SOLID. The principle can be summarized as follows:
- 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.
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:
- Compile-time dependencies: Determined when the code is compiled. These are dependencies on specific types and libraries.
- Runtime dependencies: Determined when the application is running. These include instances of objects that a class might require during execution.
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:
- High-level modules: These are more abstract, relate to the problem domain, and are typically concerned with business logic or process orchestration. They are further removed from input/output (I/O) operations like file handling or database access.
- Low-level modules: These deal with specifics like I/O operations, data access, or interaction with external systems. They are closer to the “plumbing” of the application.
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:
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:
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:
- Define abstractions (
ICalculationContext
) that are not tied to specific implementations. - Inject dependencies through the constructor.
- Ensure that your high-level logic in
CalculationEngine
is isolated from the low-level details inDefaultCalculationContext
.
This makes the CalculationEngine
more flexible, testable, and easier to maintain.
Key Takeaways
- Most classes should depend on abstractions, not implementation details.
- Abstractions should be free of implementation details and should define what needs to happen, not how.
- Classes should be explicit about their dependencies, ideally through constructor injection.
- Use Dependency Injection to manage dependencies, making your codebase more modular and testable.
- Structure your solution to leverage dependency inversion, reducing tight coupling and increasing flexibility.