SOLID Design Principles: A Guide to Writing Maintainable and Extensible Code in C#

Kaushal Pareek
Geek Culture
Published in
4 min readJan 17, 2023
Photo by Florian Olivo on Unsplash

The SOLID design principles are a set of guidelines for object-oriented programming that can help you create more maintainable and extensible code. These principles were first introduced by Robert C. Martin in his 2000 paper “Design Principles and Design Patterns” and have since become a widely accepted standard for software development. The SOLID principles consist of five principles: Single Responsibility Principle, Open-Closed Principle, Liskov Substitution Principle, Interface Segregation Principle and Dependency Inversion Principle. In this article, we will discuss these SOLID principles in detail and provide examples of how to apply them in C#.

Single Responsibility Principle (SRP) — A class should have one and only one reason to change, meaning that a class should have only one responsibility.

Example:

class Order {
public void AddItem(string item) {...}
public void RemoveItem(string item) {...}
public void CalculateTotal() {...}
public void PrintInvoice() {...}
}

In the above example, the Order class has four responsibilities: adding items, removing items, calculating the total, and printing the invoice. To adhere to the SRP, these responsibilities should be split into separate classes, such as Order, OrderItem, and Invoice.

Open-Closed Principle (OCP) — A class should be open for extension but closed for modification, meaning that a class should be designed in a way that allows new functionality to be added without modifying existing code.

Example:

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

In this example, new shapes can be added without modifying the existing codebase by creating a new class that inherits from the Shape class and implements the Area method.

Liskov Substitution Principle (LSP) — A subclass should be able to replace its superclass without affecting the correctness of the program, meaning that a subclass should be a subtype of its superclass.

Example:

class Rectangle {
public double Width { get; set; }
public double Height { get; set; }
}
class Square : Rectangle {
public double Width {
get { return base.Width; }
set { base.Width = base.Height = value; }
}
public double Height {
get { return base.Height; }
set { base.Width = base.Height = value; }
}
}

In this example, the Square class is a subtype of the Rectangle class and can be used interchangeably with it.

Interface Segregation Principle (ISP) — A class should not be forced to implement interfaces it does not use, meaning that interfaces should be split into smaller, more specific interfaces.

Example:

interface IShape {
void Draw();
void Rotate();
void Resize();
}
class Circle : IShape {
public void Draw() {...}
public void Rotate() {...}
public void Resize() {...}
}

In this example, the Circle class is forced to implement the Rotate and Resize methods, even though they may not be necessary for its functionality. To adhere to the ISP, these methods should be split into separate interfaces, such as IDrawable and IResizable.

Dependency Inversion Principle (DIP) — High-level modules should not depend on low-level modules, but rather both should depend on abstractions. In other words, the implementation details of a module should not be tightly coupled to the implementation details of other modules.

In C#, this principle can be achieved by using interfaces and dependency injection. By defining an interface for a class, we can create a contract that defines the expected behavior of the class without specifying the implementation details. This allows other classes to depend on the interface rather than the concrete implementation, making the code more flexible and less prone to change.

Example:

interface IDatabase {
void Save(string data);
string Load();
}
class SqlServer : IDatabase {
public void Save(string data) { /* code to save data to a SQL Server database */ }
public string Load() { /* code to load data from a SQL Server database */ }
}
class MyApp {
private IDatabase _database;
public MyApp(IDatabase database) {
_database = database;
}
public void DoWork() {
_database.Save("some important data");
var data = _database.Load();
// do something with the loaded data
}
}

In this example, the MyApp class depends on the IDatabase interface rather than a concrete implementation of a database. This allows us to change the database implementation (e.g from SQL Server to Oracle) without affecting the MyApp class. This principle is achieved by injecting the dependency of IDatabase to MyApp class through constructor.

In conclusion, the SOLID design principles are a set of guidelines that can help you create more maintainable and extensible code. By following these principles, you can improve the flexibility and scalability of your code, making it easier to add new features and fix bugs.

It’s important to note that these principles are not a one-size-fits-all solution, and that there may be situations where breaking one of the SOLID principles is the best approach. However, by keeping these principles in mind and applying them as appropriate, you can create code that is easier to understand, modify, and test.

In order to master these principles, it is important to practice them in real projects and continuously look for ways to improve your code by following these principles. It takes time, practice and patience to master these principles, but the end result is definitely worth the effort.

--

--

Kaushal Pareek
Geek Culture

I have lot’s of interests and technology is one which aces that list.