Explore each of the SOLID principles step by step and learn how to apply them in your daily work as a developer, with practical examples in C#.
When developing an application, your goal should not simply be “to make it work.” It’s equally crucial not to sacrifice quality or best practices for the sake of speed. Every line of code is an opportunity to leave your mark, building a scalable, robust and maintainable product. Applying guidelines for clarity and organization in your code should be one of your main goals throughout the development process.
To achieve this, the SOLID principles become your best allies. If you aren’t familiar with them yet, I invite you to explore how each of these principles can guide you toward creating software that not only solves problems but also makes maintenance and evolution easier. These principles will help you not only make your code simple and flexible, but also easy to understand for both you and your team.
In this article, I will guide you step by step through each principle and show you how to apply them in your daily work as a developer, with practical examples in C#.
SOLID principles are a set of five recommendations that help build cleaner, more maintainable and scalable code. These principles were first introduced by Robert C. Martin, also known as “Uncle Bob,” in the late 1990s and early 2000s. Although some of these concepts already existed, Martin formalized them in his article Design Principles and Design Patterns in 2000. The acronym SOLID, however, was coined later by Michael Feathers, making the principles easier to remember and more accessible to the developer community.
The article Design Principles and Design Patterns, helped establish these principles in the mindset of developers looking to write scalable and clean code. If you’re interested in learning more about this topic, you can read the article here.
Today, SOLID has been widely adopted by software engineers worldwide, and its influence continues to grow as more developers recognize the importance of applying these practices to improve the quality and maintainability of their projects.
Each letter in SOLID represents a core principle of software design, as shown in the following image:
Now that you know their names, let’s dive deeper into each principle with detailed explanations and code examples. 😎
The Single Responsibility Principle states that:
“Each class or module should have only ONE responsibility or reason to change.”
Meaning it should have only one reason for the code to change. In other words, each class should be responsible for just one task or purpose within an application.
If a class takes on multiple responsibilities, the code becomes more difficult to understand, maintain and test. Additionally, making a change in one part of the logic could break other functionalities that, while grouped within the same class, do not depend on each other, potentially affecting the final result in a negative way.
Imagine a scenario where you have the UserManager class with CreateUser
and SendWelcomeEmail
methods.
public class UserManager
{
public void CreateUser(string name, string email)
{
// Logic to create a user
Console.WriteLine($"User {name} created");
}
public void SendWelcomeEmail(string email)
{
// Logic to send a welcome email
Console.WriteLine($"Sending welcome email to {email}");
}
}
In this case, we can see that the UserManager
class is handling two different tasks, which violates the Single Responsibility Principle. This principle states that a class should have only one responsibility.
However, in this example, it also contains logic to send an email. While sending an email may seem part of the process when creating a user, it’s not directly related to user management; it’s a notification task. Therefore, it should be handled by another class.
This design can make maintenance more complicated because if you ever need to change the email logic, you would have to modify the UserManager
class, which could potentially introduce errors in other user-related functionalities that have nothing to do with email.
Additionally, it makes testing more complex and less efficient, as you would need to test both the user creation and the email sending logic, adding unnecessary complexity.
The correct way to handle this is by dividing the responsibilities into separate classes.
public class UserManager
{
public void CreateUser(string name)
{
// Logic to create a user
Console.WriteLine($"User {name} created");
}
}
public class EmailService
{
public void SendWelcomeEmail(string email)
{
// Logic to send an email
Console.WriteLine($"Sending welcome email to {email}");
}
}
In this example, we can see that UserManager
now focuses solely on user-related actions. You can add methods like create, update or delete users without violating the Single Responsibility Principle (SRP), since all these actions fall under the same responsibility: user management.
By keeping EmailService
solely responsible for sending emails, any changes to the email logic won’t affect user management. Additionally, this separation allows you to test UserManager
independently from EmailService
, making testing faster and more efficient in identifying issues.
The Open/Closed Principle states that:
“Software should be open for extension but closed for modification.”
This means that you should be able to add new functionalities without modifying the existing code, thereby avoiding breaking already implemented and tested behavior.
This principle is crucial because it makes the code more flexible. By adding new functionality instead of modifying the same code every time, you have a more scalable system and reduce the risk of introducing errors. In this way, your codebase is more robust and easier to maintain codebase in the long run.
public class HashGenerator
{
private readonly string algorithm;
public HashGenerator(string algorithm)
{
this.algorithm = algorithm;
}
public string GenerateHash(string value)
{
return algorithm switch
{
"SHA256" => DigestUtils.Sha256Hex(value),
"SHA512" => DigestUtils.Sha512Hex(value),
"MD5" => DigestUtils.Md5Hex(value),
_ => throw new ArgumentException("Unsupported algorithm")
};
}
}
This code violates the Open/Closed Principle (OCP), which states that we should be able to add new functionality without modifying existing code. In this case:
Each time you want to support a new algorithm (such as SHA3), you would have to modify the GenerateHash
method, meaning you are not extending the code but modifying it, which does not comply with OCP.
Additionally, every time you modify the HashGenerator
class, you increase the risk of introducing errors into code that already works correctly. This increases the likelihood of bugs, especially as the class grows and handles more algorithms, making it more complex and prone to failure.
One way to solve it is by applying polymorphism. We’ll create an interface called IHashAlgorithm
. This approach allows each hashing algorithm to be treated as a separate class that implements a common interface, making HashGenerator
open to extension (you can add new algorithms) but closed to modification (you don’t have to change the existing code).
Create an interface called IHashAlgorithm, which defines a common contract for all hashing algorithms. This allows both existing and future algorithms to implement the same GenerateHash
method.
public interface IHashAlgorithm
{
string GenerateHash(string value);
}
Now, let’s create a separate class for each hashing algorithm, implementing the IHashAlgorithm
interface.
SHA256:
public class Sha256Hash : IHashAlgorithm
{
public string GenerateHash(string value)
{
return DigestUtils.Sha256Hex(value);
}
}
SHA512:
public class Sha512Hash : IHashAlgorithm
{
public string GenerateHash(string value)
{
return DigestUtils.Sha512Hex(value);
}
}
MD5:
public class Md5Hash : IHashAlgorithm
{
public string GenerateHash(string value)
{
return DigestUtils.Md5Hex(value);
}
}
Let’s refactor the HashGenerator
class to accept an instance of IHashAlgorithm
, instead of using a switch to decide which algorithm to use.
public class HashGenerator
{
private readonly IHashAlgorithm hashAlgorithm;
public HashGenerator(IHashAlgorithm hashAlgorithm)
{
this.hashAlgorithm = hashAlgorithm;
}
public string GenerateHash(string value)
{
return hashAlgorithm.GenerateHash(value);
}
}
Now, when you want to generate a hash, you simply have to create an instance of the HashGenerator
class with the algorithm you need:
// Using SHA256
IHashAlgorithm sha256 = new Sha256Hash();
HashGenerator hashGeneratorSha256 = new HashGenerator(sha256);
Console.WriteLine(hashGeneratorSha256.GenerateHash("myValue"));
// Using SHA512
IHashAlgorithm sha512 = new Sha512Hash();
HashGenerator hashGeneratorSha512 = new HashGenerator(sha512);
Console.WriteLine(hashGeneratorSha512.GenerateHash("myValue"));
// Using MD5
IHashAlgorithm md5 = new Md5Hash();
HashGenerator hashGeneratorMd5 = new HashGenerator(md5);
Console.WriteLine(hashGeneratorMd5.GenerateHash("myValue"));
With this approach, we have correctly applied the Open/Closed Principle. Now, HashGenerator
is extensible without needing to modify its code when new algorithms are added. Each algorithm is implemented as a separate class, following the common contract defined by the IHashAlgorithm
interface, making the system more flexible and easier to maintain. This example was inspired by the Open-Closed Principle class from The Essentials of Industry-L course.
The Liskov Substitution Principle states that:
“Objects of a derived class must be replaceable by objects of the base class without affecting the correct functioning of the program.”
This means that if you have a base class and several derived classes, you should be able to use any of the derived classes in place of the base class without changing the program’s behavior. This way, the derived classes can act as substitutes for the base class without breaking the system’s logic.
This principle is crucial because it makes the code function polymorphically without issues. If a method expects an object of the base class, it should behave the same way if you pass it an object of a derived class. When derived classes change the behavior of the base class in an unexpected or inappropriate way, it breaks the LSP, which can lead to errors in the code.
Imagine we are working on a system that handles different types of birds, and our base class is called Bird
, which contains a Fly()
method because, in theory, all birds fly. However, while a penguin is a bird, it cannot fly.
With the current implementation, we could override the Fly()
method in the derived class Penguin()
, but we would have to make this method throw an exception, since penguins cannot fly. This not only breaks the expectation that all birds can fly, but also violates the Liskov Substitution Principle.
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("Flying");
}
}
public class Penguin : Bird
{
public override void Fly()
{
throw new NotImplementedException("Penguins can't fly");
}
}
This breaks the Liskov Principle because we cannot substitute Penguin for Bird without issues. The code expects all birds to be able to fly, which is not true for penguins.
One way to solve it is to redefine the class hierarchy so that not all birds are required to fly. We could create a more general class, like Bird
, with a Move()
method, and then specify the type of movement in derived classes such as FlyingBird
and Penguin
that swims.
public abstract class Bird
{
public abstract void Move();
}
public class FlyingBird : Bird
{
public override void Move()
{
Fly();
}
public void Fly()
{
Console.WriteLine("Flying");
}
}
public class Penguin : Bird
{
public override void Move()
{
Swim();
}
public void Swim()
{
Console.WriteLine("Swimming");
}
}
Bird
can replace the base class without breaking the expected behavior.Bird
object, you can pass any derived class, such as FlyingBird
or Penguin
, and the Move()
method will work correctly based on the bird type.In summary, the Liskov Substitution Principle (LSP) is violated when a derived class cannot replace the base class without altering the program’s behavior. In this case, a penguin should not be treated as a flying bird, since that behavior does not apply to it. By redesigning the hierarchy, we allow each bird to define its own appropriate behavior.
The Interface Segregation Principle states that:
“Clients should not be forced to depend on interfaces they do not use.”
Instead of creating large, generalized interfaces, smaller and more specific interfaces should be created. This prevents classes from being forced to implement methods they don’t need.
Maintenance becomes much easier and more scalable, as you would make targeted changes to the relevant interface rather than making large modifications. Otherwise, you would be introducing unnecessary complexity into the system.
Let’s look at an example that violates the Interface Segregation Principle (ISP). We have the IDevice interface, which aims to control different types of devices, like printers, scanners and mobile phones. However, it contains methods that are only useful for some devices, but not for all.
public interface IDevice
{
void Start();
void Stop();
void PrintDocument(string document);
}
In this case, the PrintDocument
method would work perfectly for a printer, but imagine you also have a scanner among your devices, which doesn’t print. Even so, it would be forced to implement the PrintDocument
method because it’s part of the interface. This would lead to throwing an exception, since a scanner doesn’t have the ability to print.
public class Scanner : IDevice
{
public void Start()
{
Console.WriteLine("Fan started");
}
public void Stop()
{
Console.WriteLine("Fan stopped");
}
public void PrintDocument(string document)
{
throw new NotImplementedException();
}
}
To correctly apply the Interface Segregation Principle, we should divide the IDevice
interface into more specific interfaces, aligned with the different functionalities that the devices offer.
public interface IStartable
{
void Start();
}
public interface IStoppable
{
void Stop();
}
public interface IPrintable
{
void PrintDocument(string document);
}
Now we can implement only the interfaces that each class really needs.
Printer
public class Printer : IStartable, IStoppable, IPrintable
{
public void Start()
{
Console.WriteLine("Printer started");
}
public void Stop()
{
Console.WriteLine("Printer stopped");
}
public void PrintDocument(string document)
{
Console.WriteLine($"Printing: {document}");
}
}
Scanner
public class Scanner : IStartable, IStoppable
{
public void Start()
{
Console.WriteLine("Scanner started");
}
public void Stop()
{
Console.WriteLine("Scanner stopped");
}
}
In this example, each class implements only what it truly needs, rather than being forced to implement a general interface with unnecessary methods. Additionally, if new functionalities need to be added, you can simply create new interfaces without affecting the existing ones.
The Interface Segregation Principle (ISP) encourages the creation of small, specific interfaces instead of large, general ones. This way, each class depends only on the methods it actually needs, making the code easier to understand, test and maintain. 💡 A clear sign that an interface is poorly designed is when it forces classes to implement methods they don’t need.
The Dependency Inversion Principle states that:
“High-level modules should not depend on low-level modules. Both should depend on abstractions.”
This principle is crucial for creating modular and flexible systems. If high-level classes depend on low-level implementations, any change in those implementations could affect the entire system. However, if high-level classes depend on abstractions (interfaces), implementations can change without impacting the rest of the code.
Imagine that you have an EmailService
class and a NotificationService
class. In this design, NotificationService
depends directly on EmailService
, which violates the Dependency Inversion Principle (DIP).
public class EmailService
{
public void SendEmail(string message)
{
Console.WriteLine("Sending email: " + message);
}
}
public class NotificationService
{
private EmailService emailService = new EmailService();
public void Notify(string message)
{
emailService.SendEmail(message);
}
}
Among the points to highlight are:
Coupling: The NotificationService
class depends directly on the EmailService
class. This means that if we need to change the way notifications are sent (for example, by adding SMS), we would have to modify NotificationService
to work with the new implementation.
Limits scalability and extensibility: When NotificationService
is tightly coupled to EmailService
, it becomes less flexible. Each time we want to change the notification method, we have to modify NotificationService
for every new functionality, making the code less scalable and modular.
To apply this principle correctly, both NotificationService
and EmailService
should depend on an interface, rather than NotificationService
depending directly on EmailService
.
Create the INotificationService interface, which will define a contract for any type of notification service (email, SMS, etc.).
public interface INotificationService
{
void SendMessage(string message);
}
Now, EmailService
will implement the INotificationService
interface.
public class EmailService : INotificationService
{
public void SendMessage(string message)
{
Console.WriteLine("Sending email: " + message);
}
}
NotificationService
will now depend on the abstraction INotificationService
, which allows it to be decoupled from any specific type of notification.
public class NotificationService
{
private readonly INotificationService notificationService;
public NotificationService(INotificationService notificationService)
{
this.notificationService = notificationService;
}
public void Notify(string message)
{
notificationService.SendMessage(message);
}
}
When creating an instance of NotificationService
, we can inject any implementation of INotificationService
, such as EmailService
or another notification type.
INotificationService emailService = new EmailService();
NotificationService notificationService = new NotificationService(emailService);
notificationService.Notify("Hello, Dependency Inversion!");
This solution is more effective because it is decoupled: NotificationService
no longer depends directly on EmailService
and is now prepared to work with any notification service that implements the INotificationServiceinterface
. This makes the code easier to test and maintain, allowing implementations to change without affecting the higher-level class.
🎉 And that’s it! In this article, you’ve learned about the five SOLID principles with code examples to help you fully understand each one. From now on, keep these in mind to apply them in all your development projects. 🚀
Thanks for reading, and see you in the next article! 👋✨
Leomaris Reyes is a Software Engineer from the Dominican Republic, with more than 5 years of experience. A Xamarin Certified Mobile Developer, she is also the founder of Stemelle, an entity that works with software developers, training and mentoring with a main goal of including women in Tech. Leomaris really loves learning new things! 💚💕 You can follow her: Twitter, LinkedIn , AskXammy and Medium.