Telerik blogs

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#.

Let’s Start!

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.

What Exactly Are These SOLID Principles?

Each letter in SOLID represents a core principle of software design, as shown in the following image:

SOLID Principles:  Single Responsibility principle - Open/closed principle - Liskov Substitution principle - Interface Segregation principle - Dependency Inversion principle

Now that you know their names, let’s dive deeper into each principle with detailed explanations and code examples. 😎

S: Single Responsibility Principle (SRP)

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.

Let’s See an Example That Violates the Single Responsibility Principle

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.

How to Refactor for Adherence to the SRP? 🤔

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.

O: Open/Closed Principle (OCP)

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.

Let’s See an Example That Violates the Open/Closed Principle

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.

How to Refactor for Adherence to the OCP? 🤔

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).

Step 1: Create the IHashAlgorithm Interface

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);
}

Step 2: Implement Separate Classes for Each Algorithm

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); 
    } 
}

Step 3: Refactor HashGenerator to Use Polymorphism

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);
    }
}

Step 4: Using the HashGenerator Class

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.

L: Liskov’s Subtitution Principle (LSP)

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.

Let’s See an Example That Violates the Liskov Substitution Principle

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.

How to Refactor for Adherence to the LSP? 🤔

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"); 
        } 
    }
  • This approach respects the LSP because it allows each bird to have its own behavior. All birds can move, but in different ways depending on the derived class.
  • There’s no need to throw exceptions to handle special cases—any derived class of Bird can replace the base class without breaking the expected behavior.
  • If your program expects a 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.

I: Interface Segregation Principle (ISP)

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 See an Example That Violates the Interface Segregation Principle

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(); 
    } 
}

How to Refactor for Adherence to the ISP? 🤔

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.

D: Dependency Inversion Principle (DIP)

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.

Let’s See an Example That Violates the Dependency Inversion Principle

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.

How to Refactor for Adherence to the DIP? 🤔

To apply this principle correctly, both NotificationService and EmailService should depend on an interface, rather than NotificationService depending directly on EmailService.

Step 1: Create an Interface

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); 
}

Step 2: Implement the Interface in EmailService

Now, EmailService will implement the INotificationService interface.

public class EmailService : INotificationService 
{ 
    public void SendMessage(string message) 
    { 
	    Console.WriteLine("Sending email: " + message); 
    } 
}

Step 3: Modify NotificationService to Depend on the Interface

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); 
    } 
}

Step 4: Using the Service

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.

Wrap-up

🎉 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! 👋✨

References


C#
LeomarisReyes
About the Author

Leomaris Reyes

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.

Related Posts

Comments

Comments are disabled in preview mode.