Home/Blog/Programming/S.O.L.I.D. Principles of Object-Oriented Programming in C#
Home/Blog/Programming/S.O.L.I.D. Principles of Object-Oriented Programming in C#

S.O.L.I.D. Principles of Object-Oriented Programming in C#

Ryan Thelin
May 29, 2024
16 min read

Become a Software Engineer in Months, Not Years

From your first line of code, to your first day on the job — Educative has you covered. Join 2M+ developers learning in-demand programming skills.

The SOLID principles are a set of golden rules that aim to improve the design and maintainability of software. These principles were first introduced in the early 2000s and have since become widely accepted as best practices for developers working with object-oriented programming languages. SOLID principles are particularly relevant for agile development, as they help create flexible, scalable, and easy to modify code.

Employers often look for candidates who have a strong understanding of the SOLID principles, as they can help to reduce costs and improve the long-term sustainability of software projects.

There are several advantages to following the SOLID principles when designing and building software, including:

  • Improved maintainability: You can create code that is easier to maintain and modify over time because the SOLID principles encourage the creation of modular, flexible code that is less prone to errors and more resistant to changes in requirements.

  • Reduced complexity: The SOLID principles help to reduce the complexity of software by promoting the use of abstraction and encapsulation, which can make it easier to understand and work with the code.

  • Enhanced flexibility: These principles encourage the creation of flexible code that is open to extension but closed to modification, which encourages flexibility without breaking existing functionality.

  • Increased scalability: The SOLID principles can help to make software more scalable, as they encourage the use of abstractions and decoupled dependencies, which can help to prevent the codebase from becoming overly complex and difficult to manage.

This article will explore each of the five SOLID principles in-depth, providing code examples and illustrations to help you better understand how they work.

Understand SOLID and other object-oriented design principles

Cover
Grokking the Low Level Design Interview Using OOD Principles

With hundreds of potential problems to design, preparing for the object-oriented design (OOD) interview can feel like a daunting task. However, with a strategic approach, OOD interview prep doesn’t have to take more than a few weeks. In this course, you’ll learn the fundamentals of object-oriented design with an extensive set of real-world problems to help you prepare for the OOD part of a typical software engineering interview process at major tech companies like Apple, Google, Meta, Microsoft, and Amazon. By the end of this course, you will get an understanding of the essential object-oriented concepts like design principles and patterns to ace the OOD interview. You will develop the ability to efficiently breakdown an interview design problem into multiple parts using a bottom-up approach. You will be familiar with the scope of each interview problem by accurately defining the requirements and presenting its solution using class, use case, sequence, and activity diagrams.

50hrs
Intermediate
8 Playgrounds
4 Quizzes

What are SOLID principles?#

SOLID is a mnemonic device for 5 design principles of object-oriented programs (OOP) that result in readable, adaptable, and scalable code. SOLID can be applied to any OOP program.

The 5 principles of SOLID are:

  • Single-responsibility principle
  • Open-closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

SOLID principles were developed by computer science instructor and author Robert C. Martin (sometimes called “Uncle Bob”) in 2000 and quickly became a staple of modern object-oriented design (OOD). The SOLID acronym became commonplace when these principles gained widespread popularity in the programming world.

Now, SOLID has also been adopted in both agile development and adaptive software development.

The best way to understand SOLID is to break down each of the 5 principles and see how they look in code. So, let’s do just that!


S: Single-responsibility principle#

SRP: Splitting a non-SRP class
SRP: Splitting a non-SRP class

“A class should only have a single responsibility, that is, only changes to one part of the software’s specification should be able to affect the specification of the class.” -Robert C. Martin#

The single-responsibility principle (SRP) states that each class, module, or function in your program should only do one job. In other words, each should have full responsibility for a single functionality of the program. The class should contain only variables and methods relevant to its functionality.

Classes can work together to complete larger complex tasks, but each class must complete a function from start to finish before it passes the output to another class.

Martin explained this by saying “a class should have only one reason to change”. Here the “reason” is that we want to change the single functionality this class pursues. If we do not want this single functionality to change, we will never change this class because all components of the class should relate to that behavior.

Therefore, we could change all but one class in the program without breaking the original class.

SRP makes it easy to follow another well-respected principle of OOP, encapsulation. It is easy to hide data from the user when all data and methods for a job are within the same single-responsibility class.

If you add a getter and setter method to a single-responsibility class, the class meets all criteria of an encapsulated class.

The benefit of programs that follow SRP is that you can change the behavior of a function by editing the single class responsible for it. Also, if a single functionality breaks, you know where the bug will be in the code and can trust that only that class will break.

This factor also helps with readability because you only have to read a class until you determine its functionality.

Implementation#

Let’s look at an example of how SRP can be applied to make our RegisterUser class more readable.

// does not follow SRP
public class RegisterService
{
    public void RegisterUser(string username)
    {
        if (username == "admin")
            throw new InvalidOperationException();

        SqlConnection connection = new SqlConnection();
        connection.Open();
        SqlCommand command = new SqlCommand("INSERT INTO [...]");//Insert user into database. 

        SmtpClient client = new SmtpClient("smtp.myhost.com");
        client.Send(new MailMessage()); //Send a welcome email. 
    }
}

The program above does not follow SRP because RegisterUser does three different jobs: register a user, connect to the database, and send an email.

This type of class would cause confusion in larger projects, as it is unexpected to have email generation in the same class as the registration.

There are also many things that could cause this code to change like if we make a switch in a database schema or if we adopt a new email API to send emails.

Instead, we need to split the class into three specific classes that each accomplish a single job. Here’s what our same class would look like with all other jobs refactored to separate classes:


public void RegisterUser(string username)
{
	if (username == "admin")
		throw new InvalidOperationException();

	_userRepository.Insert(...);
	
	_emailService.Send(...);
}

This achieves the SRP because RegisterUser only registers a user and the only reason it would change is if more username restrictions are added. All other behavior is maintained in the program but is now achieved with calls to userRepository and emailService.


O: Open-closed principle#

OCP: Modifying OldClass instead of extending
OCP: Modifying OldClass instead of extending

“Software entities … should be open for extension, but closed for modification.” Robert C. Martin#

This statement, at first, seems like a contradiction since it asks you to program entities (class/function/module) to be both open and closed. The open-closed principle (OCP) calls for entities that can be widely adapted but also remain unchanged. This leads us to create duplicate entities with specialized behavior through polymorphism.

Through polymorphism, we can extend our parent entity to suit the needs of the child entity while leaving the parent intact.

Our parent entity will serve as an abstract base class that can be reused with added specializations through inheritance. However, the original entity is locked to allow the program to be both open and closed.

The advantage of OCP is that it minimizes program risk when you add new uses for an entity. Instead of reworking the base class to fit a work-in-progress feature, you create a derived class separate from the classes currently present throughout the program.

We can then work on this unique derived class, confident that any changes we make to it will not affect the parent or any other derived class.

Implementation#

OCP implementations often rely on polymorphism and abstraction to code behavior at a class level rather than hard-coding for certain situations. Let’s see how we can correct an area calculator program to follow OCP:

// Does not follow OCP
public double Area(object[] shapes)
{
    double area = 0;
    foreach (var shape in shapes)
    {
        if (shape is Rectangle)
        {
            Rectangle rectangle = (Rectangle) shape;
            area += rectangle.Width*rectangle.Height;
        }
        else
        {
            Circle circle = (Circle)shape;
            area += circle.Radius * circle.Radius * Math.PI;
        }
    }

    return area;
}

public class AreaCalculator
{
    public double Area(Rectangle[] shapes)
    {
        double area = 0;
        foreach (var shape in shapes)
        {
            area += shape.Width*shape.Height;
        }

        return area;
    }
}

This program does not follow OCP because Area() is not open to extension and can only ever handle Rectangle and Circle shapes. If we want to add support for Triangle, we’d have to modify the method, so it is not closed to modification.

We can achieve OCP by adding an abstract class Shape that all types of shapes inherit.

public abstract class Shape
{
    public abstract double Area();
}

public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public override double Area()
    {
        return Width*Height;
    }
}

public class Circle : Shape
{
    public double Radius { get; set; }
    public override double Area()
    {
        return Radius*Radius*Math.PI;
    }
}

public double Area(Shape[] shapes)
{
    double area = 0;
    foreach (var shape in shapes)
    {
        area += shape.Area();
    }

    return area;
}

Now each subtype of shape handles its own area calculation through polymorphism. This opens the Shape class to extension because a new shape can easily be added with its own area calculation without error.

Further, nothing in the program modifies the original shape, and it will not need to be modified in the future. As a result, the program now achieves the OCP principle.

Learn Object-Oriented Programming in C#

Cover
Learn Object-Oriented Programming in C#

Object-oriented programming (OOP) has been around for decades. As the original object-oriented language, C# is a mainstay in the world of computer programming. All the concepts of object-oriented programming that C# developers love to have in their arsenal are explained in detail along with relevant coding examples and exercises in this course. This course is unique in its nature as it follows a project-based learning approach. Throughout the course, a real-world example of vending machine is followed to help the learners develop a firm understanding of the stated OOP concepts. The learners will not only be able to play around with the project, they’ll also be able to see all the concepts in action in this project.

9hrs
Intermediate
8 Challenges
6 Quizzes

L: Liskov substitution principle#

LSP: seamless substitution for the B class in existing code
LSP: seamless substitution for the B class in existing code

"Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program."Robert c. Martin#

The Liskov substitution principle (LSP) is a specific definition of a subtyping relation created by Barbara Liskov and Jeannette Wing. The principle says that any class must be directly replaceable by any of its subclasses without error.

In other words, each subclass must maintain all behavior from the base class along with any new behaviors unique to the subclass. The child class must be able to process all the same requests and complete all the same tasks as its parent class.

In practice, programmers tend to develop classes based on behavior and grow behavioral capabilities as the class becomes more specific. The advantage of LSP is that it speeds up the development of new subclasses as all subclasses of the same type share a consistent use.

You can trust that all newly created subclasses will work with the existing code. If you decide that you need a new subclass, you can create it without reworking the existing code.

Some critics argue that this principle is not consistent with all program types since abstract supertypes that have no implementation cannot be replaced by subclasses that are designed for implementation.

Implementation#

Most implementations of LSP involve polymorphism to create class-specific behavior for the same calls. To demonstrate the LSP principle, let’s see how we can change a fruit categorization program to implement LSP.

This example does not follow LSP:

namespace SOLID_PRINCIPLES.LSP
{
    class Program
    {
        static void Main(string[] args)
        {
            Apple apple = new Orange();
            Debug.WriteLine(apple.GetColor());
        }
    }
    public class Apple
    {
        public virtual string GetColor()
        {
            return "Red";
        }
    }
    public class Orange : Apple
    {
        public override string GetColor()
        {
            return "Orange";
        }
    }
}

This does not follow LSP because the Orange class could not replace the Apple class without altering the program output. The GetColor() method is overridden by the Orange class and therefore would return that an apple is orange.

To change this, we’ll add an abstract class for Fruit that both Apple and Orange will implement.

namespace SOLID_PRINCIPLES.LSP
{
    class Program
    {
        static void Main(string[] args)
        {
            Fruit fruit = new Orange();
            Debug.WriteLine(fruit.GetColor());
            fruit = new Apple();
            Debug.WriteLine(fruit.GetColor());
        }
    }
    public abstract class Fruit
    {
        public abstract string GetColor();
    }
    public class Apple : Fruit
    {
        public override string GetColor()
        {
            return "Red";
        }
    }
    public class Orange : Fruit
    {
        public override string GetColor()
        {
            return "Orange";
        }
    }
}

Now, any subtype (Apple or Orange) of the Fruit class can be replaced with the other subtype without error thanks to the class-specific behavior of GetColor(). As a result, this program now achieves the LSP principle.


I: Interface segregation principle#

ISP: Only inherit applicable methods
ISP: Only inherit applicable methods

"Many client-specific interfaces are better than one general-purpose interface."Robert C. Martin#

The interface segregation principle (ISP) requires that classes only be able to perform behaviors that are useful to achieve its end functionality. In other words, classes do not include behaviors they do not use.

This relates to our first SOLID principle in that together these two principles strip a class of all variables, methods, or behaviors that do not directly contribute to their role. Methods must contribute to the end goal in their entirety.

Any unused part of the method should be removed or split into a separate method.

The advantage of ISP is that it splits large methods into smaller, more specific methods. This makes the program easier to debug for three reasons:

  1. There is less code carried between classes. Less code means fewer bugs.

  2. A single method is responsible for a smaller variety of behaviors. If there is a problem with a behavior, you only need to look over the smaller methods.

  3. If a general method with multiple behaviors is passed to a class that doesn’t support all behaviors (such as calling for a property that the class doesn’t have), there will be a bug if the class tries to use the unsupported behavior.

Implementation#

To see how the ISP principle looks in code, let’s see how a program changes with and without following the ISP principle.

First, the program that doesn’t follow ISP:

// Not following the Interface Segregation Principle  
  
  public interface IWorker  
  {  
      string ID { get; set; }  
      string Name { get; set; }  
      string Email { get; set; }  
      float MonthlySalary { get; set; }  
      float OtherBenefits { get; set; }  
      float HourlyRate { get; set; }  
      float HoursInMonth { get; set; }  
      float CalculateNetSalary();  
      float CalculateWorkedSalary();  
  }  
  
  public class FullTimeEmployee : IWorker  
  {  
      public string ID { get; set; }  
      public string Name { get; set; }  
      public string Email { get; set; }  
      public float MonthlySalary { get; set; }  
      public float OtherBenefits { get; set; }  
      public float HourlyRate { get; set; }  
      public float HoursInMonth { get; set; }  
      public float CalculateNetSalary() => MonthlySalary + OtherBenefits;  
      public float CalculateWorkedSalary() => throw new NotImplementedException();  
  }  
  
  public class ContractEmployee : IWorker  
  {  
      public string ID { get; set; }  
      public string Name { get; set; }  
      public string Email { get; set; }  
      public float MonthlySalary { get; set; }  
      public float OtherBenefits { get; set; }  
      public float HourlyRate { get; set; }  
      public float HoursInMonth { get; set; }  
      public float CalculateNetSalary() => throw new NotImplementedException();  
      public float CalculateWorkedSalary() => HourlyRate * HoursInMonth;  
  }

This program does not follow ISP because the FullTimeEmployee class does not need the CalculateWorkedSalary() function, and the ContractEmployeeclass does not need the CalculateNetSalary().

Neither of these methods advance the goal of these classes. Instead, they are implemented because they are derived classes of the IWorker interface.

Here’s how we could refactor the program to follow the ISP principle:

// Following the Interface Segregation Principle  
  
    public interface IBaseWorker  
    {  
        string ID { get; set; }  
        string Name { get; set; }  
        string Email { get; set; }  
         
         
    }  
  
    public interface IFullTimeWorkerSalary : IBaseWorker  
    {  
        float MonthlySalary { get; set; }  
        float OtherBenefits { get; set; }  
        float CalculateNetSalary();  
    }  
  
    public interface IContractWorkerSalary : IBaseWorker  
    {  
        float HourlyRate { get; set; }  
        float HoursInMonth { get; set; }  
        float CalculateWorkedSalary();  
    }  
  
    public class FullTimeEmployeeFixed : IFullTimeWorkerSalary  
    {  
        public string ID { get; set; }  
        public string Name { get; set; }  
        public string Email { get; set; }  
        public float MonthlySalary { get; set; }  
        public float OtherBenefits { get; set; }  
        public float CalculateNetSalary() => MonthlySalary + OtherBenefits;  
    }  
  
    public class ContractEmployeeFixed : IContractWorkerSalary  
    {  
        public string ID { get; set; }  
        public string Name { get; set; }  
        public string Email { get; set; }  
        public float HourlyRate { get; set; }  
        public float HoursInMonth { get; set; }  
        public float CalculateWorkedSalary() => HourlyRate * HoursInMonth;  
    }  

In this version, we’ve split the general interface IWorker into one base interface, IBaseWorker, and two child interfaces IFullTimeWorkerSalary and IContractWorkerSalary.

The general interface contains methods that all workers share. The child interfaces split up methods by worker type, FullTime with a salary or Contract that gets paid hourly.

Now, our classes can implement the interface for that type of worker to access all methods and properties in the base class and the worker-specific interface.

The end classes now only contain methods and properties that further their goal and thus achieve the ISP principle.


D: Dependency inversion principle#

DIP: Changes to Objects A and B will not affect the other
DIP: Changes to Objects A and B will not affect the other

“One should depend upon abstractions, [not] concretions.” Robert C. Martin#

The dependency inversion principle (DIP) has two parts:

  1. High-level modules should not depend on low-level modules. Instead, both should depend on abstractions (interfaces)
  2. Abstractions should not depend on details. Details (like concrete implementations) should depend on abstractions.

The first part of this principle reverses traditional OOP software design. Without DIP, programmers often construct programs to have high-level (less detail, more abstract) components explicitly connected with low-level (specific) components to complete tasks.

DIP decouples high and low-level components and instead connects both to abstractions. High and low-level components can still benefit from each other, but a change in one should not directly break the other.

The advantage of this part of DIP is that decoupled programs require less work to change. Webs of dependencies across your program mean that a single change can affect many separate parts.

If you minimize dependencies, changes will be more localized and require less work to find all affected components.

The second part can be thought of as “the abstraction is not affected if the details are changed”. The abstraction is the user-facing part of the program.

The details are the specific behind-the-scenes implementations that cause program behavior visible to the user. In a DIP program, we could fully overhaul the behind-the-scenes implementation of how the program achieves its behavior without the user’s knowledge.

This process is known as refactoring.

This means you won’t have to hard-code the interface to work solely with the current details (implementation). This keeps our code loosely coupled and allows us the flexibility to refactor our implementations later.

Implementation#

We’ll create a general business program with an interface, high-level, low-level, and detailed components.

First, let’s create an interface with the getCustomerName() method. This will face our users.


public interface ICustomerDataAccess
{
    string GetCustomerName(int id);
}

Now, we’ll implement details that will depend on the ICustomerDataAccess interface. Doing so achieves the second part of the DIP principle.

public class CustomerDataAccess: ICustomerDataAccess
{
    public CustomerDataAccess() {
    }

    public string GetCustomerName(int id) {
        return "Dummy Customer Name";        
    }
}

We’ll now create a factory class that implements the abstract interface ICustomerDataAccess and returns it in a usable form. The returned CustomerDataAccess class is our low-level component.

public class DataAccessFactory
{
    public static ICustomerDataAccess GetCustomerDataAccessObj() 
    {
        return new CustomerDataAccess();
    }
}

Finally, we’ll implement a high-level component CustomerBuisnessLogic that also implements the interface ICustomerDataAccess. Notice that our high-level component does not implement our low-level component but merely uses it.


public class CustomerBusinessLogic
{
    ICustomerDataAccess _custDataAccess;

    public CustomerBusinessLogic()
    {
        _custDataAccess = DataAccessFactory.GetCustomerDataAccessObj();
    }

    public string GetCustomerName(int id)
    {
        return _custDataAccess.GetCustomerName(id);
    }
}

Here’s the full program in both code and visual chart:

public interface ICustomerDataAccess
{
    string GetCustomerName(int id);
}

public class CustomerDataAccess: ICustomerDataAccess
{
    public CustomerDataAccess() {
    }

    public string GetCustomerName(int id) {
        return "Dummy Customer Name";        
    }
}

public class DataAccessFactory
{
    public static ICustomerDataAccess GetCustomerDataAccessObj() 
    {
        return new CustomerDataAccess();
    }
}

public class CustomerBusinessLogic
{
    ICustomerDataAccess _custDataAccess;

    public CustomerBusinessLogic()
    {
        _custDataAccess = DataAccessFactory.GetCustomerDataAccessObj();
    }

    public string GetCustomerName(int id)
    {
        return _custDataAccess.GetCustomerName(id);
    }
}
Visual chart of our data access program
Visual chart of our data access program

What to learn next#

The SOLID principles are an excellent way to improve your code and make modifications easier. It can be difficult to achieve them all in a single program if you’re just starting out, so first focus on one at a time. Eventually, you’ll write SOLID programs by habit.

However, SOLID is just one step toward becoming an ace object-oriented developer. Next, you should get comfortable using the four components of OOP:

  • Polymorphism
  • Abstraction
  • Inheritance
  • Encapsulation

Educative’s Learn Object-Oriented Programming in C# course walks through each of these components in-depth with interactive projects to help you learn. You’ll learn how and why to implement these OOP concepts in your own code. By the end, you’ll have a strong understanding of the most advanced OOP concepts and be able to apply them in C#.

Happy learning!

Continue reading about OOP#

Frequently Asked Questions

What are the SOLID principles of OOP in C#?

The five SOLID principles are as follows:

  • Single-responsibility
  • Open-closed
  • Liskov substitution
  • Interface segregation
  • Dependency inversion

  

Free Resources