SOLID Design Principles in C#

solid principles

What are SOLID Design Principles?

SOLID Design principles, also known as SOLID principles are a set of few of the most basic but powerful design principles in software development. They allow you to write manageable, scalable, and testable applications. SOLID is an acronym for the 5 basic principles mentioned below.

  1. S-Single Responsibility Principle
  2. O-Open-Closed Principle
  3. L-Liskov Substitution Principle
  4. I-Interface Segregation Principle
  5. D-Dependency Inversion Principle

This article includes examples in C# but these desing principles are applicable to all object oriented languages like Java, c++ etc.

What are Software Design Principles?

Software design principles provide the way to effectively manage complex code. These are the principles defined by various software gurus based on their experience.

In this article, you will get familiar with a special set of software design principles, SOLID. These principles heavily use interface, so if you are new to them, consider reading this article.

Software Design principles are not just limited to these 5. There are other principles too such as DRY (Don’t Repeat Yourself), Divide and Conquer etc. They are ever evolving practices based on developers’ experience.

Let’s dive into these principles one by one

1. Single Responsibility Principle (SRP)

Definition: Software entities (classes, modules, methods, etc.) should have one, and only one, reason to change.

Explanation: A class should be responsible for doing only one thing.

Problem: If a class is performing many tasks then we have many reasons to change it which means more testing and more possibility of introducing bugs.

Example: Let’s take an example of a registration form, how it can break SRP and how to fix them.

Registration class does below functions

  1. Get input from user.
  2. Validate Input.
  3. Save data into database.
  4. Trigger confirmation email.

One can create a class for this as below

class RegisterationForm
{
    private void RegisterUser()
    {
	//Gether input
	//Validate
	//Database update
	//Confirmation email
    }
}

How it breaks SRP?

A class (and a method) doing so many things and thus broken SRP. If you want to change the storage from MS SQL to SQL Server, you will end up changing class and this can lead to undesirable effects. You will also have to test other functionalities in that class to make sure they are not affected.

“Remember, changes are not always as small. They can modify to global variables and that can become a nightmare”.

How to fix SRP violation?

Here we can split this class into 4 different classes as RegistrationForm, Validator, DatabaseHelper, and EmailHelper.

class RegisterationForm
{
    private void RegisterUser()
    {
       
    }
}
class Validator
{
    private void ValidateInputs()
    {
    }
}
class DatabaseHelper
{
    private void SaveUser()
    {
    }
}
class EmailHelper
{
    private void SendEmail()
    {
    }
}

Now we have divided one large class into multiple smaller classes. They are easy to maintain and unit testable. You can safely make changes in one class without affecting others.

2. Open-Closed Principle (OCP):

Definition: Software entities (classes, modules, methods, etc.) should be open for extension, but closed for modification.

Explanation: If you want to add or change some behaviors of a class then do it by extending the class instead of updating it. Inheritance, delegates, and extension methods are some of the ways to achieve this.

Problem: Extending the behavior of a class by modifying its code will make your class bigger. That makes it difficult to manage and can introduce bugs. It will also require an extensive amount of testing.

Example: Let’s take an example for Animal class. See how it can break OCP and how to fix that.

Animal class support cat and a dog. Look at the code snippet below

class Animal
{
    public string Type { get; set; }
    public void MakeSound()
    {
	if(Type == "Dog")
        {
	       //Bark
        }
	else if(Type == "Cat")
        {
		//Meow
        }
    }
}

How it breaks OCP?

If want to add one more type of animal then you have to make changes in MakeSound() method. There can be tens of thousands of Animals. Adding them all will make the class extremely large and the class will be very difficult to manage.

How to fix OCP violation?

Solution: Create an interface and implement behaviors in extending classes.

interface IAnimal
{
	void MakeSound();
}
class Dog: IAnimal
{
    public void MakeSound()
    {
		//bark
    }
}
class Cat : IAnimal
{
	public void MakeSound()
	{
		//Meow
	}
}

Now you can add any number of animals without modifying the existing code but you need to change its call from

Animal animal = new Animal();
animal.MakeSound("Dog");

to

IAnimal dog = new Dog();
dog.MakeSound();

3. L-Liskov Substitution Principle (LSP):

Definition: If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program.

Explanation: Superclass objects should be replaceable by subtype objects without breaking the behavior.

Problem: You might have many subtypes and one supertype. While making changes in the superclass, you need to consider if its subtypes can implement the same functionality. If they don’t then you won’t be able to replace the superclass object with its subclass object. If such a situation arises then you might have to redesign all the related classes.

Example: Let’s take an example for Bird class. The bird can fly, right? So, if we write Fly method inside the Bird method and use it from child classes like Sparrow, Eagle then it works fine. We can replace the object of Bird with the object of Sparrow or Eagle without breaking anything.

class Bird
{
    public virtual void Fly()
    {
        //Generalized fly
    }
}
class Sparrow : Bird
{
    public override void Fly()
    {
        //Set lower elavation
        base.Fly();
    }
}
class Eagle: Bird
{
    public override void Fly()
    {
        //Set higher elavation
        base.Fly();
    }
}

We can call them from the client as below

Bird bird = new Bird();

and if needed, we can replace Bird object with Sparrow’s.

Bird bird = new Sparrow();

Fine so far.

How it breaks LSP?

Now we want to support another Bird, Ostrich. Ostrich is a bird but can’t fly so we can’t replace Bird object with Ostrich object.

How to fix LSP violation?

If creating a new subclass violates LSP then we should consider creating a specialized class that extends the general one.

In this situation, we can remove Fly from Bird and introduce another “Specialized” class called FlyingBird that will extend the Bird class and have Fly method. Now Sparrow and Eagle can extend FlyingBird while Ostrich can extend Bird and we can replace Bird with Ostrich.

class Bird
{
}
class FlyingBird : Bird
{
    public virtual void Fly()
    {
        //Generalized fly
    }
}
class Sparrow : FlyingBird
{
    public override void Fly()
    {
        //Set lower elavation
        base.Fly();
    }
}
class Eagle : FlyingBird
{
    public override void Fly()
    {
        //Set higher elavation
        base.Fly();
    }
}
class Ostrich : Bird
{
}

We now have a special class for flying birds and more generalized for birds that can’t fly. Bird class objects can be replaced by objects of all three classes. Of course, you won’t be able to use Fly method because All Birds Can’t Fly.

4. Interface Segregation Principle (ISP):

Definition: Clients should not be forced to depend upon interfaces that they do not use.

Explanation: We use interfaces and classes have to implement their members. Sometimes we add more methods to the interface and classes have to implement them. It could be possible that not all classes are required to implement them.

Problem: When classes are forced to implement methods that they don’t require, they have to throw NotImplementedException or have empty methods. These extra methods will be available for a caller and can be confusing.

Example: Let’s take an example for the ILibraryItem interface. See how it is used at the moment and how adding more features makes it break ISP.

ILibraryItem can have Title, Author, Borrower, BorrowDate, etc as below

interface ILibraryItem
{
    string Title { get; set; }
    string Author { get; set; }
    string Borrower { get; set; }
    DateTime BorrowDate { get; set; }
}

Now we can add multiple classes that can implement this interface

class Book : ILibraryItem
{
    public string Title { get; set; }
    public string Author { get; set; }
    public string Borrower { get; set; }
    public DateTime BorrowDate { get; set; }
}
class Magazine : ILibraryItem
{
    public string Title { get; set; }
    public string Author { get; set; }
    public string Borrower { get; set; }
    public DateTime BorrowDate { get; set; }
}

It works fine till now.

How it breaks ISP?

Now we want to keep AudioBooks and DVDs also. They have Tutor property. If we modify the interface to have that additional property then Book and Magazine will be forced to implement it and AudioBook and DVD will be forced to implement Author, which is not required hence violating ISP.

How to fix ISP violation?

If adding features to an interface is forcing classes to implement unnecessary features then we should avoid it by segregating the interface into multiple interfaces. In this situation, we can have interfaces as below

interface ILibraryItem
{
    string Title { get; set; }
    string Borrower { get; set; }
    DateTime BorrowDate { get; set; }
}
interface ILibraryItemPaper : ILibraryItem
{
    string Author { get; set; }
}
interface ILibraryItemDigital : ILibraryItem
{
    string Tutor { get; set; }
}

and use them as below

class Book : ILibraryItemPaper
{
    public string Title { get; set; }
    public string Author { get; set; } 
    public string Borrower { get; set; }
    public DateTime BorrowDate { get; set; }
}
class DVD : ILibraryItemDigital
{
    public string Title { get; set; }
    public string Tutor { get; set; }
    public string Borrower { get; set; }
    public DateTime BorrowDate { get; set; }
}

5. Dependency Inversion Principle (DIP):

Definition:

  1. High-level modules should not depend on low-level modules rather both should depend on the abstraction.
  2. Abstractions should not depend on details but details should depend on abstractions.

Explanation: This principle focuses on eliminating tight dependency between classes so that can be tested independently. This principle has two parts.

Part 1: Classes should not depend on each other rather they should depend on the interface.

Part 2: If we design an interface while focusing on implementation then it becomes difficult to implement another class from it. That’s why we should aim for generalization, that’s what part 2 says.

Example: Let’s consider the registration process. We have a User class that has Register method. After successful registration, we want to notify user so we have an EmailSender class that has SendEmail method for notifying users.

class User
{
    public void Register()
    {
        //some logic
       
        var emailSender = new EmailSender();
        emailSender.SendEmail();
    }
}
class EmailSender
{
    public void SendEmail()
    {
        //some logic
    }
}

Now caller will use this code to call Register method

var user = new User();
user.Register();

How it breaks DIP?

The problem with this implementation is, we can not change the way users are notified without charging User class and User class is also not unit testable because of tight coupling with EmailSender class.

How to fix DIP violation?

We can create an interface INotifier with a Notify method and implement EmailSender from it and pass interface object to Register method of User class.

interface INotifier
{
    void Notify();
}
class EmailSender : INotifier
{
    public void Notify()
    {
        //some logic
    }
}

and use it in the User class as below

class User
{
    public void Register(INotifier notifier)
    {
        //some logic
       
        notifier.Notify();
    }
}

Now User class has no dependency on EmailSender class and you can switch this class easily with another without modifying its code.

The caller has control now on the way of notifying users.

var user = new User();
user.Register(new EmailSender());

In the future, if we want to notify users using WhatsApp messages then we can pass WhatsApp object from the caller, without modifying User class as below

var user = new User();
user.Register(new WhatsApp());

We have solved both the issues there.

  1. There is no tight coupling between User and EmailSender, now they both depend on Abstraction (INotifier).
  2. User class is unit testable because we can easily mock INotifier using any of mocking frameworks.

Let’s come back to the second part of the definition, “Abstractions should not depend on details. Details should depend on abstractions.”

We have designed an interface with the name INotifier with Notify method to replace of IEmailSender. This is exactly what the definition says. The interface should not be designed by considering implementation, it should be generic. If we had created IEmailSender with SendEmail method then how odd would it have been to derive WhatsApp from IEmailSender and call SendEmail of WhatsApp object.

Summary

Here is a brief summary of SOLID design principles

  1. SRP is all about dividing a class into smaller classes to protect them against frequent changes.
  2. OCP is all about extending functionality without modifying the classes. Less modifications means less testing, means less possibility to introduce bugs.
  3. LSP is all about writing futuristic code that makes it easy to swap objects on the need basis.
  4. ISP is all about avoiding the implementation of unnecessary methods by dividing an interface into multiple interfaces.
  5. DIP is all about removing dependency between concrete types by using abstraction.

Leave a Reply

Your email address will not be published.

Coding Crest Back To The Top