S.O.L.I.D. Principles in C# (Series)

S.O.L.I.D. Principles in C# (Series)

C#
S.O.L.I.D.
Programming
Software Engineering
Design Principles
2020-08-04

The Dependency Inversion Principle (DIP) tells us: “depend on abstractions, not on concretions.” High-level modules shouldn’t directly depend on low-level modules. Instead, both should rely on abstractions (interfaces or abstract classes).

This practice makes your code more flexible and testable. Since everything depends on interfaces, you can easily swap out concrete classes (e.g. for mocks in unit tests).

By decoupling higher-level logic from lower-level details, your code remains adaptable to new requirements and evolving technologies. This also reduces the ripple effect where a small change in one low-level module forces updates in multiple high-level modules. Over the lifetime of a project, this can significantly lower maintenance costs and headaches.

Suppose your NotificationManager sends emails using a concrete EmailService. That direct dependency makes it hard to switch to SMS or other channels.

// Without DIP public class EmailService { public void SendEmail(string message) { // Implementation } } public class NotificationManager { private EmailService _emailService = new EmailService(); public void NotifyUser(string message) { _emailService.SendEmail(message); } }

With DIP, both the manager and the low-level service depend on an interface. Now you can inject EmailService or SmsService (or any other) without changing the manager.

// With DIP public interface IMessageService { void SendMessage(string message); } public class EmailService : IMessageService { public void SendMessage(string message) { // Send email } } public class SmsService : IMessageService { public void SendMessage(string message) { // Send SMS } } public class NotificationManager { private readonly IMessageService _messageService; public NotificationManager(IMessageService messageService) { _messageService = messageService; } public void NotifyUser(string message) { _messageService.SendMessage(message); } }

In a typical .NET Core application, you often rely on the built-in dependency injection (DI) container. Registering your abstractions and concretions in the DI container ensures the framework automatically injects the correct service implementation where needed.

// Program.cs in a .NET Core app var builder = WebApplication.CreateBuilder(args); // Register services builder.Services.AddTransient<IMessageService, EmailService>(); builder.Services.AddTransient<NotificationManager>(); var app = builder.Build(); // Now, whenever NotificationManager is requested, // it automatically gets an EmailService injected.

By registering IMessageService along with EmailService, any class requiring IMessageService in its constructor will receive anEmailService. This is a perfect fit for DIP because it maintains a strict rule that higher-level classes never directly construct or even know about the concrete class it depends on.

Another benefit of DIP is how effortless it becomes to mock or stub out dependencies in your unit tests. Since you depend on an interface, your tests can supply any stand-in implementation without reworking production code.

// Example of unit test mocking the interface in DIP using Moq; using Xunit; public class NotificationManagerTests { [Fact] public void TestNotificationManager() { // Arrange var mockService = new Mock<IMessageService>(); mockService.Setup(s => s.SendMessage(It.IsAny<string>())).Verifiable(); var manager = new NotificationManager(mockService.Object); // Act manager.NotifyUser("Hello DIP!"); // Assert mockService.Verify(s => s.SendMessage("Hello DIP!"), Times.Once); } }

Notice how the test only cares about IMessageService. This keeps our test isolated from external concerns like SMTP servers or SMS gateways and focuses purely on the manager’s logic.

  • Interface Naming: Keep interfaces and abstractions intuitive. Overly generic interfaces (e.g. “IService”) make code difficult to read.
  • Avoid Over-engineering: While DIP is great, don’t create abstractions for the sake of it. Extra layers of indirection can complicate simpler scenarios.
  • Proper Scoping: When using DI in .NET Core, make sure to choose the correct lifetime (Transient, Scoped, Singleton) for your services to avoid unexpected behavior or resource leaks.
  • Keep It Flexible: Resist the urge to bind all dependencies at compile time. Stay open to plugging in different implementations if business needs evolve.
  • Readability First: DIP is supposed to enhance maintainability, not decrease it. If the solution becomes more confusing, reevaluate your abstractions.

We’ve now walked through each letter in S.O.L.I.D.:

  • Single Responsibility — Keep each class focused on one job.
  • Open/Closed — Extend, don’t modify existing code.
  • Liskov Substitution — Subclasses should be seamlessly substitutable for their base classes.
  • Interface Segregation — Keep interfaces lean; don’t force clients to implement unused methods.
  • Dependency Inversion — Depend on abstractions to keep your design flexible.

By incorporating these principles into your everyday coding habits, you’ll find your software is easier to read, test, and maintain. While no principle is a silver bullet, together they form a robust foundation for clean, scalable architecture in .NET Core or any OOP-based platform.

I hope you’ve enjoyed this deep dive into each principle. Feel free to reach out if you have any questions, stories, or best practices to share. Happy coding!

– Nate

Go to Part Series Overview