Understanding the SOLID Principles with C#: Practical Experience

Artem A. Semenov
12 min readMay 31, 2023

--

Image from Unsplash

In the realm of object-oriented programming, you’ll hardly take a step without encountering the SOLID principles. They’re the bedrock upon which robust, maintainable, and scalable software is built, and any developer who wants to rise above the rest cannot afford to bypass these principles. This is especially true for developers in the C# community.

The SOLID principles are not just about writing code, they’re about crafting software that stands the test of time and change. But understanding and implementing these principles can be a complex task, especially for those new to them. They involve a paradigm shift, a change in the way you think about and structure your software. In this article, we’ll take a practical, hands-on look at these principles, using C# as our medium. We’ll dive deep into each principle, learning what they entail, their importance, common mistakes in their application, and how to use them effectively to improve your C# programming.

You may be an experienced C# developer looking to sharpen your skills, or a newcomer eager to learn what the pros already know. Whichever category you fall into, this article is for you. So, roll up your sleeves, fire up your IDE, and let’s get down to the nuts and bolts of the SOLID principles in C#.

Prepare yourself for a journey that will transform not just how you code, but how you think about your code.

Background Information

Before diving into the specifics, it’s essential to understand the history and evolution of the SOLID principles, as well as how they have contributed to the robustness, maintainability, and scalability of software.

The SOLID principles were introduced by Robert C. Martin, also known as Uncle Bob, in his 2000 paper, “Design Principles and Design Patterns”. They were later given the acronym SOLID by Michael Feathers. Since then, these principles have served as a North Star, guiding developers on their path to building high-quality software. The principles consist of the Single Responsibility Principle (SRP), Open-Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP).

The SOLID principles represent a fundamental philosophy of software development, promoting the creation of software structures that are easy to maintain, understand, and extend.

In C#, like in other object-oriented languages, the SOLID principles have been instrumental in promoting clean, efficient code that can handle the pressures of change and growth. They have informed the design and architecture of countless successful software systems, proving their worth beyond a doubt.

Despite their wide acceptance, the SOLID principles remain a challenging topic for many developers. Misunderstanding or misapplying these principles can lead to software that’s brittle, difficult to understand, and cumbersome to change — the exact opposite of what they’re meant to achieve.

This article aims to demystify these principles and provide practical, hands-on advice on how to apply them effectively in your C# projects. With real-world examples, common mistakes, and expert advice, you’ll learn how to use the SOLID principles as a roadmap to better software. So let’s embark on this journey of understanding SOLID principles, using C# as our vehicle of exploration.

Understanding SOLID Principles: Single Responsibility Principle (SRP)

The first principle in the SOLID acronym is the Single Responsibility Principle. At its core, SRP is about restraining ourselves from overloading a class or a module with multiple responsibilities. In other words, it posits that a class should have one, and only one, reason to change. This is because a class with more than one responsibility becomes harder to maintain and understand.

Understanding SRP

The term ‘responsibility’ in this context refers to a reason for a class to change. By keeping our classes focused on a single task, we can create more robust software that is resistant to bugs and is easy to comprehend, maintain, and extend.

A common misconception is that a class should only do one ‘thing’ or have one method. In reality, SRP implies a class should revolve around a single functionality or concept, which might involve multiple methods or procedures related to that functionality.

C# Example: Consider an example of an Employee class, which manages employee data and also saves it to a database.

public class Employee
{
public string EmployeeName { get; set; }
public int EmployeeId { get; set; }

//Other employee related properties and methods...

public void SaveEmployee()
{
// Code to save employee details to database...
}
}

The Employee class, in this case, is doing more than it should. It is not only managing employee data, but also handling the database operations, which is a separate responsibility. This violation of SRP makes the Employee class harder to maintain and test.

The SRP would guide us to separate these concerns:

public class Employee
{
public string EmployeeName { get; set; }
public int EmployeeId { get; set; }

// Other employee related properties and methods...
}

public class EmployeeDB
{
public void SaveEmployee(Employee emp)
{
// Code to save employee details to database...
}
}

In the revised example, Employee class is only concerned with managing employee data, while EmployeeDB class is tasked with handling the database operations. This separation of concerns makes the code easier to understand, test, and maintain.

Understanding and applying SRP will help you build more maintainable and less error-prone classes, leading to cleaner and more efficient code. It requires careful thought and design, but the payoff is worth the effort. In the following sections, we will explore the rest of the SOLID principles and see how they interplay with SRP to result in well-designed software.

Open-Closed Principle (OCP)

The second pillar in the SOLID principles is the Open-Closed Principle. The principle, as the name suggests, maintains that software entities such as classes, modules, and functions should be open for extension but closed for modification.

Understanding OCP

What does “open for extension, but closed for modification” mean? It implies that we should design our modules, classes, and functions in such a way that when new functionalities are required, we should be able to add those functionalities without altering the existing code. In essence, this means that a well-designed class should never require modification of its source code while adding new features.

This principle promotes a more robust system that is less prone to bugs and is easier to maintain and comprehend. Violating OCP could introduce risks every time you modify a class, possibly introducing new bugs or breaking existing functionalities.

C# Example: Consider a Rectangle class with Height and Width properties and a AreaCalculator class that calculates the total area of a list of rectangles.

public class Rectangle
{
public double Height { get; set; }
public double Width { get; set; }
}

public class AreaCalculator
{
public double TotalArea(Rectangle[] rectangles)
{
double total = 0;
foreach (var rectangle in rectangles)
{
total += rectangle.Height * rectangle.Width;
}
return total;
}
}

Now, if we need to add a Circle class and calculate its area, we'll have to modify the AreaCalculator class, which violates the Open-Closed Principle.

A better approach would be to define a base Shape class with an Area method, which can be overridden by derived classes like Rectangle and Circle.

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

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

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

public class AreaCalculator
{
public double TotalArea(Shape[] shapes)
{
double total = 0;
foreach (var shape in shapes)
{
total += shape.Area();
}
return total;
}
}

With this structure, we can add as many new shapes as we like without having to modify the AreaCalculator class, thereby adhering to the Open-Closed Principle. By thinking ahead and designing our classes with the OCP in mind, we can create software that is more resilient to change and easier to maintain.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle is the third principle in the SOLID acronym. It is named after Barbara Liskov, who introduced it in a 1987 conference keynote. The principle states that “objects in a program should be replaceable with instances of their subtypes without altering the correctness of the program.”

Understanding LSP

In simple terms, the Liskov Substitution Principle requires that every child class or derived class should be substitutable for their parent or base class without causing issues. If your code fails when substituting a base type with a subtype, you’re violating LSP, which could result in unpredictable behavior, bugs, and difficulties in maintenance.

C# Example: To illustrate this, let’s consider an example. Suppose we have a Bird base class and a method Fly:

public class Bird
{
public virtual void Fly()
{
// Implementation to fly
}
}

public class Pigeon : Bird
{
public override void Fly()
{
// Pigeon's implementation to fly
}
}

Now, if we introduce a Penguin class that also inherits from Bird, we have a problem:

public class Penguin : Bird
{
public override void Fly()
{
throw new NotSupportedException("Penguins can't fly!");
}
}

Our Penguin class violates the Liskov Substitution Principle because we can't substitute a Bird with a Penguin without altering the correctness of the program. A Bird is expected to fly, but a Penguin can't fly.

To adhere to LSP, we could refactor the classes to have an IFlyable interface and only implement it in birds that can fly:

public interface IFlyable
{
void Fly();
}

public class Bird
{
// common bird attributes and methods
}

public class Pigeon : Bird, IFlyable
{
public void Fly()
{
// Pigeon's implementation to fly
}
}

public class Penguin : Bird
{
// Penguin doesn't implement IFlyable
}

In this refactored version, Pigeon and any other birds that can fly can implement IFlyable, and Penguin does not have to. This way, we adhere to the Liskov Substitution Principle and ensure that we can substitute base types with subtypes without issues.

It’s worth mentioning that violations of LSP can often be subtle and not as straightforward as this example. The principle is invaluable in guiding you towards a more robust and maintainable object-oriented design.

Interface Segregation Principle (ISP)

Moving on to the fourth principle of SOLID, the Interface Segregation Principle suggests that “clients should not be forced to depend upon interfaces that they do not use.”

Understanding ISP

The Interface Segregation Principle puts an emphasis on keeping interfaces lean and focused. Essentially, it states that it’s better to have several specific interfaces rather than one general-purpose interface. If an interface becomes ‘fat’ or bloated with many methods, it’s highly likely that the implementing classes will not need all methods, leading to inefficiency and potential issues.

By adhering to the ISP, we can create systems that are easier to refactor, less prone to errors, and more understandable.

C# Example: Let’s consider an example where we have an IWorker interface used by different types of workers:

public interface IWorker
{
void Work();
void Eat();
void Sleep();
}

public class HumanWorker : IWorker
{
public void Work()
{
// Implementation for human worker to work
}
public void Eat()
{
// Implementation for human worker to eat
}
public void Sleep()
{
// Implementation for human worker to sleep
}
}

public class RobotWorker : IWorker
{
public void Work()
{
// Implementation for robot worker to work
}
public void Eat()
{
throw new NotSupportedException("Robots don't eat!");
}
public void Sleep()
{
throw new NotSupportedException("Robots don't sleep!");
}
}

In this scenario, the RobotWorker class is forced to implement the Eat and Sleep methods, which it doesn't use, violating the Interface Segregation Principle.

To adhere to ISP, we could break down the IWorker interface into more specific interfaces:

public interface IWorker
{
void Work();
}

public interface IEater
{
void Eat();
}

public interface ISleeper
{
void Sleep();
}

public class HumanWorker : IWorker, IEater, ISleeper
{
public void Work()
{
// Implementation for human worker to work
}
public void Eat()
{
// Implementation for human worker to eat
}
public void Sleep()
{
// Implementation for human worker to sleep
}
}

public class RobotWorker : IWorker
{
public void Work()
{
// Implementation for robot worker to work
}
}

With the refactored version, each class only needs to implement the interfaces it uses. It’s a more efficient and clear design that aligns with the Interface Segregation Principle.

Dependency Inversion Principle (DIP)

We arrive at the final pillar of the SOLID principles, the Dependency Inversion Principle. It’s a fundamental aspect of many software designs and architectures, including the likes of microservices and clean architecture. DIP prescribes that “High-level modules should not depend on low-level modules. Both should depend on abstractions.”

Understanding DIP

The Dependency Inversion Principle is about decoupling and flexibility. It encourages developers to write code that depends upon abstractions rather than upon concrete details. It’s all about controlling the future evolution of the software. The principle aids in minimizing the coupling between code modules and making them interchangeable by depending on the same abstraction.

C# Example: To demonstrate this principle, let’s imagine we are developing a notification system for a console application. A Notification class uses a Email class to send notifications.

public class Email
{
public void SendEmail(string email, string message)
{
// Send email
}
}

public class Notification
{
private Email _email;
public Notification()
{
_email = new Email();
}

public void Notify(string message)
{
_email.SendEmail("admin@mysite.com", message);
}
}

The Notification class is directly dependent on the Email class. If we want to introduce another notification method, like SMS, we'll have to modify the Notification class, which violates the Open-Closed Principle and the Dependency Inversion Principle.

To adhere to DIP, we can introduce an interface IMessenger and implement it in Email and Sms classes:

public interface IMessenger
{
void SendMessage(string recipient, string message);
}

public class Email : IMessenger
{
public void SendMessage(string email, string message)
{
// Send email
}
}

public class Sms : IMessenger
{
public void SendMessage(string phoneNumber, string message)
{
// Send SMS
}
}

public class Notification
{
private IMessenger _messenger;
public Notification(IMessenger messenger)
{
_messenger = messenger;
}

public void Notify(string message)
{
_messenger.SendMessage("contact", message);
}
}

Now, Notification depends on the abstraction IMessenger, not the concrete Email or Sms. If we need to change the notification method, we can just substitute the IMessenger implementation, adhering to DIP.

Best Practices and Common Pitfalls

As we’ve gone through each of the SOLID principles, we’ve highlighted various best practices to keep in mind. Here, let’s summarize them and discuss a few common pitfalls that developers often encounter.

Best Practices

Keep each class and method focused on a single responsibility: Your classes and methods should each have one job. This makes your code easier to read, understand, and test.

Program to an interface, not an implementation: By depending on abstractions rather than concrete implementations, your code becomes much more flexible and resilient to change.

Ensure your classes remain open for extension but closed for modification: Rather than modifying existing code, try to extend it through inheritance. This reduces the risk of introducing new bugs to existing functionality.

Use abstractions to decouple your classes: By depending on abstractions, you reduce the dependencies between your classes, making your code easier to refactor or rewrite.

Common Pitfalls

Misunderstanding or misapplying the principles: While the SOLID principles are a great guideline, they’re not a silver bullet. They don’t apply to every situation, and it’s important to understand when and how to apply them.

Over-engineering: While following SOLID principles, there might be a tendency to over-engineer solutions, making the code overly complex. SOLID principles should help to clarify and simplify design, not complicate it.

Ignoring performance implications: Applying SOLID principles might sometimes lead to more objects and abstractions, which could have performance implications. It’s essential to keep this in mind, especially in performance-critical applications.

Overuse of design patterns: Design patterns are excellent tools, but just like SOLID principles, they’re not applicable everywhere. Recognize when a pattern adds clarity and when it adds unnecessary complexity.

These practices and common pitfalls, you can effectively apply SOLID principles and avoid common mistakes. Remember, the key to successful software design is to strike a balance between maintainability, readability, and performance. SOLID principles, when applied judiciously, help maintain this balance.

Conclusion

We’ve taken quite the journey, from the theoretical understanding of SOLID principles to concrete examples and real-world case studies. We’ve explored common issues, innovative solutions, and the practical implications of these principles. We’ve delved into the practical application of these principles in the C# programming language, which has served as a good vessel for our exploration due to its strong support for object-oriented programming.

The SOLID principles, when applied effectively, lead to software that is more maintainable, flexible, and robust. They reduce the friction associated with evolving and scaling your systems, making your life as a developer significantly easier.

It’s important to remember that these principles are not absolute laws. They are guidelines, and like all guidelines, they are most effective when applied with discretion and understanding. Blindly following SOLID could lead to over-engineered, unnecessarily complex systems. The key is to strike the right balance, understanding the trade-offs involved, and applying the principles where they make sense.

As you grow as a software engineer, these principles will become an integral part of your toolkit. You’ll start recognizing opportunities to apply them and appreciate the elegance they bring to your design. Always keep in mind the problem at hand, the constraints, and the goals of the software you’re building.

The end goal is not just to write software that adheres to a set of principles, but to solve problems and deliver value. The SOLID principles are merely tools that, when used appropriately, can greatly aid us in achieving that goal.

As you continue your journey in software development, I encourage you to not only grasp these principles theoretically but to apply them in your projects. There’s a vast difference between understanding a principle and knowing how and when to apply it.

With that, I hope this exploration into the SOLID principles has been enlightening and practical. May these principles guide you well in your journey as a software craftsman.

--

--

No responses yet