Summary
This article explores the Dependency Inversion Principle (DIP)βhigh-level modules should not depend on low-level modules; both should depend on abstractions. You'll learn how to break tight coupling through dependency injection, design flexible architectures, and make code testable and maintainable. Through practical C# examples, we'll transform rigid, tightly-coupled systems into loosely-coupled, extensible designs.
Prerequisites
Strong understanding of C# interfaces and abstract classes
Familiarity with dependency injection concepts
Knowledge of all previous SOLID principles recommended
Experience with IoC containers helpful but not required
The Dependency Inversion Principle states: High-level modules should not depend on low-level modules. Both should depend on abstractions.
Additionally: Abstractions should not depend on details. Details should depend on abstractions.
Let's unpack what this means and why it matters.
Understanding High-Level vs Low-Level
Before we dive into examples, let's clarify terminology:
High-level modules contain business logic and policies (e.g., OrderProcessor
, PaymentService
)
< strong>Low-level modules handle implementation details (e.g., SqlDatabase
, FileLogger
, SmtpEmailer
)
Traditionally, high-level modules depend directly on low-level modules. DIP inverts this: both depend on abstractions.
The Notification System Problem
You're building an order processing system:
public class OrderProcessor
{
private EmailService _emailService = new EmailService();
private SmsService _smsService = new SmsService();
public void ProcessOrder(Order order)
{
// Process the order
Console.WriteLine($"Processing order {order.Id}");
// Send notifications
_emailService.SendEmail(order.CustomerEmail, "Order Confirmed", "Your order has been confirmed");
_smsService.SendSms(order.CustomerPhone, "Order confirmed");
}
}
public class EmailService
{
public void SendEmail(string to, string subject, string body)
{
// SMTP logic
Console.WriteLine($"Sending email to {to}");
}
}
public class SmsService
{
public void SendSms(string phone, string message)
{
// SMS API logic
Console.WriteLine($"Sending SMS to {phone}");
}
}
Problems with this design:
OrderProcessor
(high-level) directly depends on EmailService
and SmsService
(low-level)
Can't test OrderProcessor
without actually sending emails and SMS
Can't switch notification providers without modifying OrderProcessor
Can't add new notification types without changing OrderProcessor
Inverting the Dependency
public interface INotificationService
{
void SendNotification(string recipient, string message);
}
public class EmailNotificationService : INotificationService
{
public void SendNotification(string recipient, string message)
{
Console.WriteLine($"Sending email to {recipient}: {message}");
// SMTP logic
}
}
public class SmsNotificationService : INotificationService
{
public void SendNotification(string recipient, string message)
{
Console.WriteLine($"Sending SMS to {recipient}: {message}");
// SMS API logic
}
}
public class PushNotificationService : INotificationService
{
public void SendNotification(string recipient, string message)
{
Console.WriteLine($"Sending push notification to {recipient}: {message}");
// Push notification logic
}
}
public class OrderProcessor
{
private readonly INotificationService _notificationService;
public OrderProcessor(INotificationService notificationService)
{
_notificationService = notificationService;
}
public void ProcessOrder(Order order)
{
Console.WriteLine($"Processing order {order.Id}");
_notificationService.SendNotification(order.CustomerEmail, "Order confirmed");
}
}
Now both OrderProcessor
and the notification services depend on INotificationService
. The dependency has been inverted.
Benefits:
Easy to test with mock notifications
Switch providers without changing OrderProcessor
Add new notification types (Slack, WhatsApp) by implementing INotificationService
The Data Access Layer Trap
Consider this tightly-coupled design:
public class CustomerService
{
private SqlCustomerRepository _repository = new SqlCustomerRepository();
public Customer GetCustomer(int id)
{
return _repository.GetById(id);
}
public void SaveCustomer(Customer customer)
{
_repository.Save(customer);
}
}
public class SqlCustomerRepository
{
public Customer GetById(int id)
{
using (var connection = new SqlConnection("connection-string"))
{
// SQL logic
return new Customer();
}
}
public void Save(Customer customer)
{
using (var connection = new SqlConnection("connection-string"))
{
// SQL logic
}
}
}
CustomerService
is tightly coupled to SQL Server. Switching to MongoDB or a web API requires rewriting CustomerService
.
Applying DIP
public interface ICustomerRepository
{
Customer GetById(int id);
void Save(Customer customer);
}
public class SqlCustomerRepository : ICustomerRepository
{
private readonly string _connectionString;
public SqlCustomerRepository(string connectionString)
{
_connectionString = connectionString;
}
public Customer GetById(int id)
{
using (var connection = new SqlConnection(_connectionString))
{
// SQL logic
return new Customer();
}
}
public void Save(Customer customer)
{
using (var connection = new SqlConnection(_connectionString))
{
// SQL logic
}
}
}
public class MongoCustomerRepository : ICustomerRepository
{
private readonly IMongoDatabase _database;
public MongoCustomerRepository(IMongoDatabase database)
{
_database = database;
}
public Customer GetById(int id)
{
var collection = _database.GetCollection("customers");
return collection.Find(c => c.Id == id).FirstOrDefault();
}
public void Save(Customer customer)
{
var collection = _database.GetCollection("customers");
collection.InsertOne(customer);
}
}
public class CustomerService
{
private readonly ICustomerRepository _repository;
public CustomerService(ICustomerRepository repository)
{
_repository = repository;
}
public Customer GetCustomer(int id)
{
return _repository.GetById(id);
}
public void SaveCustomer(Customer customer)
{
_repository.Save(customer);
}
}
CustomerService
now depends on ICustomerRepository
. Switch databases by injecting a different implementation. No changes to CustomerService
.
The Logging Dependency
This pattern appears everywhere:
public class PaymentProcessor
{
public void ProcessPayment(Payment payment)
{
try
{
// Process payment
Console.WriteLine("Payment processed successfully");
}
catch (Exception ex)
{
Console.WriteLine($"Payment failed: {ex.Message}");
}
}
}
PaymentProcessor
is coupled to console logging. Want file logging? Modify the class. Want database logging? Modify again.
DIP Solution
public interface ILogger
{
void LogInfo(string message);
void LogError(string message, Exception ex);
}
public class ConsoleLogger : ILogger
{
public void LogInfo(string message)
{
Console.WriteLine($"[INFO] {message}");
}
public void LogError(string message, Exception ex)
{
Console.WriteLine($"[ERROR] {message}: {ex.Message}");
}
}
public class FileLogger : ILogger
{
private readonly string _filePath;
public FileLogger(string filePath)
{
_filePath = filePath;
}
public void LogInfo(string message)
{
File.AppendAllText(_filePath, $"[INFO] {DateTime.Now}: {message}
");
}
public void LogError(string message, Exception ex)
{
File.AppendAllText(_filePath, $"[ERROR] {DateTime.Now}: {message} - {ex.Message}
");
}
}
public class PaymentProcessor
{
private readonly ILogger _logger;
public PaymentProcessor(ILogger logger)
{
_logger = logger;
}
public void ProcessPayment(Payment payment)
{
try
{
// Process payment
_logger.LogInfo("Payment processed successfully");
}
catch (Exception ex)
{
_logger.LogError("Payment processing failed", ex);
}
}
}
PaymentProcessor
depends on ILogger
, not concrete implementations.
Dependency Injection Patterns
DIP is implemented through dependency injection. Three main patterns:
Constructor Injection (Recommended)
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly ILogger _logger;
public OrderService(IOrderRepository repository, ILogger logger)
{
_repository = repository;
_logger = logger;
}
}
Dependencies are required and immutable. Clear and testable.
Property Injection
public class OrderService
{
public IOrderRepository Repository { get; set; }
public ILogger Logger { get; set; }
}
Optional dependencies or when constructor injection isn't feasible. Less preferred because dependencies aren't guaranteed.
Method Injection
public class OrderService
{
public void ProcessOrder(Order order, ILogger logger)
{
// Use logger
}
}
When dependency varies per method call. Rarely used.
IoC Containers
Inversion of Control (IoC) containers automate dependency injection:
// Program.cs or Startup.cs
services.AddTransient();
services.AddScoped();
services.AddSingleton();
// Automatic injection
public class CustomerController
{
private readonly ICustomerRepository _repository;
private readonly ILogger _logger;
// Container automatically injects dependencies
public CustomerController(ICustomerRepository repository, ILogger logger)
{
_repository = repository;
_logger = logger;
}
}
Popular containers: Microsoft.Extensions.DependencyInjection, Autofac, Unity.
The Testing Advantage
DIP makes testing trivial:
// Without DIP - hard to test
public class OrderService
{
private SqlOrderRepository _repository = new SqlOrderRepository();
public void SaveOrder(Order order)
{
_repository.Save(order); // Hits real database!
}
}
// With DIP - easy to test
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public void SaveOrder(Order order)
{
_repository.Save(order);
}
}
// Test with mock
[Test]
public void SaveOrder_ValidOrder_CallsRepository()
{
// Arrange
var mockRepo = new Mock();
var service = new OrderService(mockRepo.Object);
var order = new Order();
// Act
service.SaveOrder(order);
// Assert
mockRepo.Verify(r => r.Save(order), Times.Once);
}
DIP and the Other Principles
DIP ties all SOLID principles together:
DIP + SRP: When classes have single responsibilities and depend on abstractions, the system is modular and flexible.
DIP + OCP: Abstractions make code open for extension. Add new implementations without modifying high-level code.
DIP + LSP: Abstractions only work if implementations are properly substitutable. LSP ensures DIP works correctly.
DIP + ISP: Small, focused interfaces make dependency injection cleaner. Classes don't drag in unused dependencies.
Common Pitfalls
1. Abstracting Everything
// Overkill
public interface IDateTime
{
DateTime Now { get; }
}
Don't abstract stable framework classes unless you truly need to control them (e.g., for testing).
2. Leaky Abstractions
// Bad - exposes SQL details
public interface IUserRepository
{
SqlDataReader ExecuteQuery(string sql);
}
Abstractions shouldn't leak implementation details.
3. Service Locator Anti-Pattern
// Bad - hidden dependency
public class OrderService
{
public void ProcessOrder()
{
var repo = ServiceLocator.Get();
}
}
Use constructor injection instead. Dependencies should be explicit.
When NOT to Apply DIP
Stable, simple classes β If a class rarely changes and has no dependencies, abstraction adds complexity
Framework types β Don't abstract string
, DateTime
, etc.
Private utility methods β Internal helpers don't need abstraction
Conclusion
The Dependency Inversion Principle is the culmination of SOLID. By depending on abstractions rather than concrete implementations, you create flexible, testable, maintainable systems. High-level business logic becomes independent of low-level implementation details.
DIP isn't about eliminating dependenciesβit's about controlling them. By inverting the direction of dependencies, you gain control over how components interact, making your architecture adaptable to change.
When you find yourself instantiating concrete classes with new
inside high-level code, pause. Ask: "Should I depend on an abstraction here?" More often than not, the answer is yes.
Congratulations! You've completed the SOLID principles series. You now have powerful tools for designing maintainable, flexible C# applications. Apply these principles consistently, and you'll write code that stands the test of time.
Key Takeaways
β
Depend on abstractions β Use interfaces and abstract classes, not concrete implementations
β
Invert dependencies β High-level and low-level modules both depend on abstractions
β
Constructor injection β Preferred method for providing dependencies
β
IoC containers β Automate dependency management in large applications
β
Testability β DIP makes unit testing trivial through mocking
β
Flexibility β Swap implementations without changing high-level code
β
Avoid over-abstraction β Abstract where it adds value, not everywhere
β
Completes SOLID β DIP ties all principles together into a cohesive design philosophy
How has DIP changed your architecture? Share your experiences below!
References and Further Reading