Overview
It is not just an aspiration to write clean, maintainable, and scalable code in software design—it is a necessity. For this purpose, developers often rely on well-established principles that provide a framework for building robust and adaptable systems. Among these principles, SOLID principles stand out as one of the cornerstones of object-oriented design. The term SOLID was coined by renowned software engineer Robert C. Martin, also known as "Uncle Bob," to represent five key principles that contribute to high-quality software development.
- S - Single Responsibility Principle (SRP): Every class should only have a single reason to change, making it easier to understand and modify. This principle emphasizes that each class should focus on a single responsibility or functionality.
- O - Open/Closed Principle (OCP) Classes: Modules, and functions should be open for extension, but closed for modification. This encourages systems to be designed to add new functionality without modifying existing code.
- L - Liskov Substitution Principle (LSP) : A superclass should be replaceable with its subclass without modifying the correctness of the program. This principle ensures that derived classes extend base classes without making them behave differently.
- I - Interface Segregation Principle (ISP): Classes should not be forced to implement interfaces they don't need. Instead of creating large, monolithic interfaces, smaller, more tailored ones should be developed.
- D - Dependency Inversion Principle (DIP): High-level modules shouldn't depend on low-level modules. To decouple components in a system, abstractions should not depend on details, but details should depend on abstractions. Additionally, abstractions should not depend on details, but details should depend on abstractions.
In this article, Ziggy Rafiq will deeply dive into each of these principles, breaking them down with practical examples written in C# 13. As your projects grow in complexity, Ziggy Rafiq will explore the best practices for incorporating these principles into your development workflow, ensuring your code remains flexible, easy to maintain, and scalable. Mastering these principles will enhance your ability to build software systems that withstand time, regardless of whether you are a seasoned developer or just starting out in software design.
Single Responsibility Principle (SRP)
As part of the SOLID design principles, the Single Responsibility Principle emphasizes that every class should have only one reason to change. A class should have a single responsibility or function. If it has multiple responsibilities, it becomes tightly coupled, and difficult to maintain, test, or modify. By adhering to SRP, we ensure that our code remains modular, easy to understand, and less prone to bugs when changes are required.
Explanation in Practice
Imagine a class that handles multiple responsibilities, such as managing user data and performing logging. This would violate SRP because the class would have multiple reasons to change—changes to how user data is handled or how logging is performed could both require modifications to the same class. A separate, more focused class should be created to handle these responsibilities.
Example of SRP Violation
As an example of a class that violates the Single Responsibility Principle, here is:
namespace Csharp_13_Solid_Principles_Examples.NoRefactor;
public class UserManager
{
public void AddUser(string username)
{
Console.WriteLine($"User {username} added.");
Console.WriteLine($"Logging: User {username} added.");
}
}
Two distinct responsibilities are handled by the UserManager class in this code:
- The database has been updated with a new user.
- A log is kept of the user addition action.
Because of this design, we have two reasons for changing this class. If we need to modify how logging works or update how user data is managed, we have to edit the same class, increasing the risk of introducing a bug.
Refactoring to Adhere to SRP
The UserManager class can be refactored to comply with SRP by delegating the logging responsibility to a separate class.
namespace Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
public interface ILogger
{
void Log(string message);
}
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
}
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class UserManager
{
private readonly ILogger _logger;
public UserManager(ILogger logger)
{
_logger = logger;
}
public void AddUser(string username)
{
Console.WriteLine($"User {username} added.");
_logger.Log($"User {username} added.");
}
}
Refactored Code Improvements
- As of now, the UserManager class is solely responsible for managing users, and the ConsoleLogger class is responsible for logging.
- The ILogger interface allows the logging mechanism to be easily swapped or extended without modifying the UserManager class, adhering to Dependency Inversion Principles (DIP).
- As logging functionality is separated from user management logic, changes to logging functionality will not impact user management logic, and vice versa.
Key Takeaway
By ensuring that each class has a single reason to change, you can create systems that are modular, flexible, and easier to understand. The Single Responsibility Principle promotes cleaner, more maintainable code. SRP enables developers to write code that is future-proof and scalable, regardless of the complexity of the application.
Open/Closed Principle (OCP)
A second principle in the SOLID design principles is the Open/Closed Principle (OCP). Essentially, it says software entities, such as classes, modules, or functions, should be extensible but not modifiable. Following OCP reduces the risk of introducing bugs and improves the flexibility and scalability of a system. It means that you can add new functionality to a class without changing its existing code.
Explanation in Practice
In the process of designing systems, requirements often change. If you frequently have to change existing classes to implement new features, you may break existing functionality. The OCP encourages developers to design their systems so that new functionality can be added by extending the existing structure rather than directly modifying the existing code.
Example of OCP Violation
Using the following class, you can calculate discounts for different types of customers:
namespace Csharp_13_Solid_Principles_Examples.NoRefactor;
public class DiscountCalculator
{
public double CalculateDiscount(string customerType, double amount)
{
if (customerType == "Regular")
{
return amount * 0.1;
}
else if (customerType == "VIP")
{
return amount * 0.2;
}
return 0;
}
}
The CalculateDiscount method must be modified if a new customer type (e.g., "Loyal") is introduced, violating the Open/Closed Principle. When the system grows, it becomes harder to maintain if the existing code is modified, which can introduce bugs.
Refactoring to Adhere to OCP
In order to comply with OCP, we can refactor the code using interfaces and inheritance to make it extensible. Each customer type can have its own discount calculation logic, and new customer types can be added without modifying DiscountCalculator.
How the Refactored Code Adheres to OCP
- By implementing the IDiscountStrategy interface, additional classes can be added to the DiscountCalculator class that implement new discount strategies (e.g., LoyalCustomerDiscount).
- There is no need to modify the DiscountCalculator class when a new discount type is introduced because its existing code does not need to be changed.
- System modularity and ease of future testing and extension are made possible by this separation of responsibilities.
Adding a New Customer Type
A new class could be created instead of modifying existing code to add a "Loyal" customer type:
namespace Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
public interface IDiscountStrategy
{
double CalculateDiscount(double amount);
}
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class LoyalCustomerDiscount : IDiscountStrategy
{
public double CalculateDiscount(double amount) => amount * 0.15;
}
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class DiscountCalculator
{
private readonly IDiscountStrategy _discountStrategy;
public DiscountCalculator(IDiscountStrategy discountStrategy) => _discountStrategy = discountStrategy;
public double CalculateDiscount(double amount) => _discountStrategy.CalculateDiscount(amount);
}
We can now use this new discount strategy without altering any logic in the DiscountCalculator class:
var loyalDiscount = new DiscountCalculator(new LoyalCustomerDiscount());
Console.WriteLine(loyalDiscount.CalculateDiscount(100));
OCP Offers the Following Benefits
- Code Extensibility: New functionality can be added without modifying existing code, reducing the risk of breaking it.
- System Flexibility: The system can adapt to changes, making scaling easier.
- Maintainability: The overall design becomes cleaner and easier to maintain by isolating changes to new components.
Key Takeaway
Flexible and scalable systems require the Open/Closed Principle to be applied. Developers can minimize the impact of changes, reduce bugs, and create systems that can be easily extended as requirements change by writing code that is open for extension but closed for modification. For building robust and future-proof software systems, this principle is a game-changer when applied consistently.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) is the third principle in the SOLID design principles. It states that objects of a superclass should be replaceable with objects of its subclass without affecting the program's correctness. A subclass should behave in a way that maintains the behavior and expectations set by its base class. When subclasses are used instead of their base classes, unexpected behavior, runtime errors, or broken functionality can result.
Explanation in Practice
It ensures that derived classes do not alter the fundamental behavior of base classes. A design becomes difficult to maintain and extend if a subclass cannot seamlessly replace its base class. The LSP method creates a hierarchy in which substitutability is always preserved, resulting in more robust and predictable systems.
Example of LSP Violation
As an example, consider the following Ostrich class derived from the Bird class:
namespace Csharp_13_Solid_Principles_Examples.NoRefactor;
public class Bird
{
public virtual void Fly() =>
Console.WriteLine("Flying...");
}
namespace Csharp_13_Solid_Principles_Examples.NoRefactor;
public class Ostrich : Bird
{
public override void Fly() =>
throw new NotImplementedException("Ostriches cannot fly.");
}
It violates the Liskov Substitution Principle in this case. Despite being a subclass of Bird, Ostrich does not support the Fly functionality defined in the base class, so substituting it for Bird will result in runtime errors. As a result, polymorphism fails to perform as expected and defeats its purpose.
Refactoring to Adhere to LSP
By redesigning the hierarchy, we can take into account the capabilities of different bird types more accurately. Instead of assuming that all birds can fly, we can define an interface or abstract class to separate the Fly behavior.
namespace Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
public interface IFlyable
{
void Fly();
}
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class Bird
{
public string Name { get; set; }
public double Weight { get; set; }
public Bird(string name, double weight)
{
Name = name;
Weight = weight;
}
public void Eat() =>
Console.WriteLine($"{Name} is eating.");
public void Sleep() =>
Console.WriteLine($"{Name} is sleeping.");
public void MakeSound() =>
Console.WriteLine($"{Name} is making a sound.");
public virtual void Move()=>
Console.WriteLine($"{Name} is moving.");
}
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class Ostrich : Bird
{
public Ostrich(string name, double weight) : base(name, weight) { }
public void Run() => Console.WriteLine($"{Name} is running...");
public override void Move()=> Run();
}
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class Sparrow : Bird, IFlyable
{
public Sparrow(string name, double weight) : base(name, weight) { }
public void Fly() => Console.WriteLine($"{Name} is flying...");
public override void Move() => Fly();
}
Bird sparrow = new Sparrow("Sparrow", 0.03);
Bird ostrich = new Ostrich("Ostrich", 150);
sparrow.Eat();
ostrich.Eat();
sparrow.Sleep();
ostrich.Sleep();
sparrow.MakeSound();
ostrich.MakeSound();
sparrow.Move();
ostrich. Move();
How the Refactored Code Adheres to LSP
- There is no longer an assumption that all birds can fly in the Bird class.
- A new IFlyable interface represents the capability of flying. Birds that can fly, such as Sparrow, implement this interface.
- It ensures that the Ostrich class, which cannot fly, is not forced to implement the Fly method.
Benefits of a Refactored Design
- There is now no need to make assumptions about the capability of flying to substitute an object. For example, any Bird can replace another without introducing any errors.
- By distinguishing between birds that can fly and those that cannot, the refactored design more accurately models real-world behavior.
- A bird like an ostrich, which is known for running, could have an additional interface called IRunnable.
Adding New Behavior
We can use the refactored design to introduce an interface for swimming if we want to add a Penguin class. Penguins cannot fly but can swim.
namespace Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
public interface ISwimmable
{
void Swim();
}
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class Penguin : Bird, ISwimmable
{
public Penguin(string name, double weight) : base(name, weight) { }
public void Swim() => Console.WriteLine($"{Name} is swimming...");
}
As a result, each bird class only implements behaviors relevant to it, maintaining substitutability and adhering to the LSP.
Key Takeaways
- Subclasses must adhere to the Liskov Substitution Principle in order not to violate the expectations set by their base classes.
- Hierarchies should be designed with interfaces or abstract classes to prevent behavioral mismatches and runtime errors.
- By adhering to LSP, you can create designs that are reliable, predictable, and easy to extend as new requirements arise.
In order to build reliable and maintainable systems, the Liskov Substitution Principle must be applied. Developers can create systems that are easier to scale and less prone to errors when they design abstractions that accurately represent shared behaviors and ensure that subclasses fulfill the promises of their base classes.
Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP), the fourth principle in the SOLID design principles, states that clients shouldn't be forced to depend on interfaces they don't use. As a result, interfaces should be narrowly focused and specific rather than being overly broad and generic. It can result in cumbersome implementations if ISP is violated, forcing classes to implement irrelevant methods.
Explanation in Practice
It is possible for implementing classes to end up with unnecessary methods when interfaces are too large or contain unrelated methods, leading to poor design and potential runtime errors. It is possible for developers to ensure that classes only implement what is necessary for their behavior by breaking down large interfaces into smaller, more focused ones. Separating code reduces bloat, improves flexibility, and makes it easier to maintain and test.
Example of ISP Violation
To illustrate how too general an interface can be, consider the following example:
namespace Csharp_13_Solid_Principles_Examples.NoRefactor.Interfaces;
public interface IWorker
{
void Work();
void Eat();
}
IWorker assumes that all workers must have both Work and Eat capabilities. This makes sense for humans, but not for robots. Forcing a Robot class to implement the Eat method results in unnecessary code:
using Csharp_13_Solid_Principles_Examples.NoRefactor.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.NoRefactor;
public class Robot : IWorker
{
public void Work()=>
Console.WriteLine("Robot is working.");
public void Eat() =>
throw new NotImplementedException();
}
Since the Robot class depends on a method (Eat) that it cannot use, it violates the Interface Segregation Principle.
Refactoring to Adhere to ISP
In order to comply with ISP, we can split the IWorker interface into smaller, more focused interfaces:
public interface IWorkable
{
void Work();
}
public interface IFeedable
{
void Eat();
}
As a result, each class only implements the interface(s) that are relevant to its behavior:
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class Human : IWorkable, IFeedable
{
public void Work() =>
Console.WriteLine("Human is working.");
public void Eat()=>
Console.WriteLine("Human is eating.");
}
How the Refactored Code Adheres to ISP
- While IWorkable focuses solely on work-related behavior, IFeedable focuses exclusively on feeding behavior.
- In order to implement both interfaces, the Human class implements both behaviors.
- Due to its lack of feeding behavior, the Robot class implements only the IWorkable interface.
- As a result, each class has been stripped of unnecessary or irrelevant dependencies, adhering to the ISP guidelines.
Benefits of Applying ISP
- Classes no longer need to implement methods they don't use, resulting in cleaner and more focused code.
- Maintainability is improved when changes are made to one interface or its implementing classes do not affect other classes.
- It is easier to add new behaviors because specific interfaces can be created without modifying existing ones.
Adding a New Worker Type
Assume we now want to add an Animal class that can eat but cannot work. With the refactored design, we simply implement IFeedable:
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class Animal : IFeedable
{
public void Eat()=>
Console.WriteLine("Animal is eating.");
}
ISP promotes flexibility and extensibility with this addition, which requires no changes to the existing interfaces or classes.
Key Takeaways
- A single responsibility should be the focus of an interface, so it should be small, specific, and focused.
- There should be no requirement for classes to implement methods they don't use.
- Codebases that adhere to ISP are cleaner, more modular, and easier to maintain.
The Interface Segregation Principle is a cornerstone of designing modular and flexible systems. By breaking down large interfaces into smaller, behavior-specific interfaces, developers can avoid unnecessary dependencies and ensure that classes are only responsible for what they truly need to do. For building systems that are scalable, easy to maintain, and future-proof, this principle is essential.
Dependency Inversion Principle (DIP)
This is the fifth and final principle of SOLID design. It emphasizes that high-level modules (e.g., core application logic) should not directly depend on low-level modules (e.g., specific implementations); instead, both should depend on abstractions (e.g., interfaces or abstract classes). Aside from that, abstractions should not depend on implementation details, but implementation details should depend on abstractions. The principle promotes loose coupling and makes applications more flexible and testable.
Explanation in Practice
High-level modules (which encapsulate the core logic of your application) remain independent of low-level modules (such as concrete services, libraries, or APIs) due to DIP. By relying on abstractions, your code becomes easier to modify, extend, and test without altering existing modules.
Example of DIP Violation
Here is an example of a high-level module (UserManager) directly relying on a low-level module (DatabaseService):
namespace Csharp_13_Solid_Principles_Examples.NoRefactor;
public class UserManager
{
private readonly DatabaseService _databaseService;
public UserManager()=>
_databaseService = new DatabaseService();
public void AddUser(string username)
{
_databaseService.AddUser(username);
Console.WriteLine($"User {username} added.");
Console.WriteLine($"Logging: User {username} added.");
}
}
namespace Csharp_13_Solid_Principles_Examples.NoRefactor;
public class DatabaseService
{
public void AddUser(string username) =>
Console.WriteLine($"User {username} added to the database.");
}
In this example:
- There is a close relationship between the UserManager class and the DatabaseService class.
- We must modify the UserManager class if we want to replace DatabaseService with another implementation (e.g., a service that logs users to a file or an external API).
- As a result, it is difficult to test, maintain, and extend the code.
Refactoring to Adhere to DIP
As a way to comply with DIP, we can introduce an abstraction in the form of an interface (IUserService) and ensure that both UserManager and DatabaseService depend on it:
namespace Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
public interface IUserService
{
void AddUser(string username);
}
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class UserManager
{
private readonly IUserService _userService;
public UserManager(IUserService userService) => _userService = userService;
private readonly ILogger _logger;
public UserManager(ILogger logger)
{
_logger = logger;
}
public void AddUser(string username)
{
_userService.AddUser(username);
Console.WriteLine($"User {username} added.");
_logger.Log($"User {username} added.");
}
}
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class DatabaseService : IUserService
{
public void AddUser(string username)=>
Console.WriteLine($"User {username} added to the database.");
}
How the Refactored Code Adheres to DIP
- The IUserService interface defines the contract for adding a user, ensuring both the UserManager (at the high level) and the DatabaseService (at the low level) are dependent on this interface.
- Loose Coupling: The UserManager class no longer depends directly on DatabaseService. Instead, it relies on the IUserService abstraction.
- It is now easier to replace DatabaseService with another implementation, such as FileUserService or APIUserService, without modifying UserManager.
The process of adding a new service is straightforward, for instance:
using Csharp_13_Solid_Principles_Examples.Refactored.Interfaces;
namespace Csharp_13_Solid_Principles_Examples.Refactored;
public class FileUserService : IUserService
{
public void AddUser(string username)=> Console.WriteLine($"User {username} saved to a file.");
}
Dependency Injection for Better Flexibility
By injecting the appropriate implementation of IUserService at runtime, we can switch between different services without changing any code in the UserManager class.
var userService = new DatabaseService();
var userManager = new UserManager(userService);
userManager.AddUser("Ziggy Rafiq");
Benefits of Applying DIP
- The UserManager class can now be tested using mock implementations of the IUserService interface.
- It is possible to add new implementations of IUserService without modifying existing code.
- The system is more extensible and maintainable because the high-level and low-level modules are decoupled.
By ensuring high-level modules depend on abstractions rather than low-level modules, you can reduce coupling, improve flexibility, and facilitate testing.
Summary
Providing a framework for writing clean, maintainable, and scalable code, the SOLID principles are the cornerstones of modern software development. Below is a detailed explanation of each principle and how it contributes to better software architecture. Each principle addresses specific design challenges, enabling developers to build robust and flexible applications.
Single Responsibility Principle (SRP)
If a class is responsible for a single feature of the software, it should only have one reason to change.
Explanation
In the SRP, a class is focused on a specific function or task. If a class has multiple responsibilities, it can inadvertently affect others, resulting in bugs and increased maintenance complexity. As each class is independent and focused, adhering to SRP simplifies debugging, testing, and scaling.
For An Example
Combined classes that handle business logic and data persistence violate SRP. Separating these concerns into distinct classes isolates changes and makes the system more flexible.
Benefit
Using SRP produces cleaner, more maintainable code with fewer, focused classes that are easier to understand and reuse.
Open/Closed Principle (OCP)
There should be no restriction on the extension, but no restriction on the modification of a class.
Explanation
A key objective of the OCP is to design classes so that new functionality can be added without modifying existing code. By adhering to this principle, you minimize the risk of introducing bugs when adding new features. This can be done by utilizing abstractions such as interfaces, inheritance, or composition.
For An Example
To encapsulate the new behavior, OCP recommends creating new classes or extending existing ones rather than modifying existing ones. Instead of modifying its core logic, a discount calculator that supports new customer types can use different strategy classes.
Benefit
Due to the fact that existing code remains stable and untouched, the OCP promotes flexibility and reduces the cost of extending software functionality.
Liskov Substitution Principle (LSP)
Substituting derived classes for their base classes must not affect the correctness of programs.
Explanation
The intent of this rule is to ensure that subclasses maintain the behavior expected by their base classes. Violations occur when subclasses change the behavior in a way that breaks client expectations.
For An Example
It might make sense to extend a Bird class by an Ostrich class, but since ostriches cannot fly, calling the Fly method on an Ostrich object would result in unexpected behavior. A better design would use an abstraction like IFlyable for birds that can fly, leaving ostriches out of this hierarchy.
Benefit
It ensures consistency of behavior and reduces the risk of unexpected runtime errors by maintaining the integrity of inheritance hierarchies.
Interface Segregation Principle (ISP)
There should be no requirement for clients to rely on interfaces they do not use.
Explanation
Rather than creating large, monolithic interfaces, the ISP advises creating small, focused interfaces that have specific functionality the implementing class needs, thus eliminating unnecessary dependencies.
For An Example
The IWorker interface with Work and Eat methods might force a Robot class to implement an irrelevant Eat method. By splitting the interface into IWorkable and IEatable, the Robot class can implement only the relevant methods.
Benefit
As a result of ISP, systems can be designed more cleanly and modularly, and unrelated changes can be made without impacting other parts of the system.
Dependency Inversion Principle (DIP)
A high-level module shouldn't depend on a low-level module; both should be abstracted, and the abstraction should be abstracted from the detail, and the detail should be abstracted from the abstraction.
Explanation
In DIP, high-level modules rely on abstractions rather than concrete implementations, making systems more flexible because swapping out low-level modules requires no changes to the high-level logic.
For An Example
By refactoring the UserManager class to depend on an abstraction like IUserService, the UserManager can work with any implementation of IUserService (e.g., a database, a file, or an API).
Benefit
By decoupling high-level logic from low-level implementation details, DIP enhances flexibility, testability, and maintainability.
What are the benefits of adhering to SOLID principles?
Developers can achieve the following by adhering to the SOLID principles:
- Ensure that your software is robust: Stiffly coupled or poorly designed code is more likely to cause bugs.
- A modular design makes it easier to adapt to new requirements or extend functionality.
- Classes and interfaces that are focused and decoupled are easier to debug, update, and understand.
- Mock implementations make it easier to isolate and test components that depend on abstractions.
Taking a broader view
As each SOLID principle addresses a specific design challenge, they are most effective when combined to create scalable, clean, and maintainable codebases. In the long run, these principles will save developers time and reduce costs by making it easier for them to develop and maintain systems.
You can find the Source Code for this article on Ziggy Rafiq GitHub Repository https://github.com/ziggyrafiq/Csharp-13-Solid-Principles-Examples and if you found this article useful please do follow Ziggy Rafiq on LinkedIn https://www.linkedin.com/in/ziggyrafiq/