Get Solid with the SOLID Principles

Sande Satoskar
15 min readJun 15, 2023

The SOLID Principles… Ah, where do I even begin? These principles are like guiding stars in the vast universe of Object-Oriented class design. They illuminate the path to creating well-structured, maintainable, and scalable code. If you’re a developer, trust me, you want to pay attention to these gems.

In this article, I’m going to take you on a journey through the magical world of SOLID principles. We’ll dive deep into each principle, unraveling its essence, and discovering the reasons behind its existence. Brace yourself for a captivating exploration that will empower you to transform your code into a work of art.

But first, let’s take a step back and uncover the origins of SOLID. Picture a visionary computer scientist named Robert J. Martin, affectionately known as Uncle Bob. Back in 2000, Uncle Bob introduced these principles in a groundbreaking paper. Later, another brilliant mind, Michael Feathers, coined the unforgettable acronym SOLID.

Uncle Bob is no ordinary coder; he’s a champion of clean code and clean architecture. You may have stumbled upon his best-selling books, “Clean Code” and “Clean Architecture.” With SOLID principles, Uncle Bob adds yet another powerful tool to our developer arsenal.

Now, let’s get to the heart of the matter. We’ll explore each principle, one by one, peeling back its layers to reveal its true nature and significance. Trust me, this is where the real magic happens. We’ll dive into captivating examples, dissecting class designs, and evolving them step by step towards perfection.

Are you ready to embark on this enlightening journey? Grab your favorite cup of coffee or tea, get cozy, and let’s begin. Together, we’ll unlock the secrets of SOLID principles and unleash their transformative power upon your projects.

Let’s dive into the captivating world of SOLID principles and unlock the true potential of your code. It’s time to infuse your creations with elegance, maintainability, and collaboration. Brace yourself for a thrilling adventure that will forever change the way you approach software development.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) is one of the SOLID principles in software development. It states that a class should have only one reason to change, and it should have a single responsibility or job. By adhering to SRP, we ensure that each class is focused, cohesive, and easier to maintain.

The SRP is crucial for code organization and maintainability. When a class has multiple responsibilities, it becomes tightly coupled to various parts of the system, making it harder to understand, modify, and test. By following SRP, we achieve code that is more modular, flexible, and easier to extend.

Now, let’s explore three different C# code examples that demonstrate the application of the Single Responsibility Principle:

Example 1: UserRepository Class

public class UserRepository
{
private DatabaseConnection _dbConnection;

public UserRepository(DatabaseConnection dbConnection)
{
_dbConnection = dbConnection;
}

public User GetById(int userId)
{
// Database query to retrieve user by ID
// ...
return user;
}

public void Save(User user)
{
// Save the user to the database
// ...
}
}

Step-by-Step Guide:
1. Identify the class that you want to apply SRP to. In this case, it is the `UserRepository` class.
2. Determine the primary responsibility of the class. In this example, the primary responsibility is handling data access for the `User` entity.
3. Remove any unrelated responsibilities from the class, such as user authentication or email notifications, to ensure it has a single responsibility.
4. Extract related responsibilities into separate classes if necessary.

Example 2: Logger Class

public class Logger
{
private readonly string _logFilePath;

public Logger(string logFilePath)
{
_logFilePath = logFilePath;
}

public void LogError(string message)
{
// Write error log to the specified file
// ...
}

public void LogInfo(string message)
{
// Write info log to the specified file
// ...
}
}

Step-by-Step Guide:
1. Identify the class that you want to apply SRP to. In this case, it is the `Logger` class.
2. Determine the primary responsibility of the class. In this example, the primary responsibility is logging messages to a file.
3. Remove any unrelated responsibilities, such as sending emails or performing calculations, from the class to maintain a single responsibility.
4. Extract related responsibilities into separate classes if needed.

Example 3: EmailSender Class

public class EmailSender
{
private SmtpClient _smtpClient;

public EmailSender(SmtpClient smtpClient)
{
_smtpClient = smtpClient;
}

public void SendEmail(string recipient, string subject, string body)
{
// Code to send an email using SmtpClient
// ...
}
}

Step-by-Step Guide:
1. Identify the class that you want to apply SRP to. In this case, it is the `EmailSender` class.
2. Determine the primary responsibility of the class. In this example, the primary responsibility is sending emails.
3. Remove any unrelated responsibilities, such as generating PDF reports or interacting with databases, from the class to ensure a single responsibility.
4. Extract related responsibilities into separate classes if it becomes necessary.

By following the Single Responsibility Principle, you ensure that each class in your C# code has a clear and distinct responsibility. This leads to more maintainable code, better testability, and increased flexibility. Remember to regularly review your classes and ensure they adhere to SRP to maintain a well-organized and scalable codebase.

By following the Single Responsibility Principle, you ensure that each class in your C# code has a clear and distinct responsibility. This leads to more maintainable code, better testability, and increased flexibility. Remember to regularly review your classes and ensure they adhere to SRP to maintain a well-organized and scalable codebase.

The Open/Closed Principle (OCP)

The Open/Closed Principle (OCP) is one of the SOLID principles in software development. It states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. The principle promotes the use of abstraction and inheritance to allow behavior extension without modifying existing code.

By adhering to the OCP, we aim to design systems that are flexible and maintainable. Instead of modifying existing code to accommodate new functionalities, we extend the system by adding new code that builds upon the existing codebase. This approach minimizes the risk of introducing bugs and reduces the impact of changes on the overall system.

Now, let’s explore three different C# code examples that demonstrate the application of the Open/Closed Principle:

Example 1: Shape Drawing

public abstract class Shape

{

public abstract void Draw();

}

public class Circle : Shape

{

public override void Draw()

{

// Implementation for drawing a circle

}

}

public class Square : Shape

{

public override void Draw()

{

// Implementation for drawing a square

}

}

// Client code

public class DrawingService

{

public void DrawShapes(IEnumerable<Shape> shapes)

{

foreach (var shape in shapes)

{

shape.Draw();

}

}

}

Step-by-Step Guide:

1. Identify the entity or behavior that you want to extend without modifying existing code. In this example, it is the ability to draw different shapes.

2. Create an abstract base class, `Shape`, that defines a contract or interface for all shapes.

3. Implement specific shapes, such as `Circle` and `Square`, by inheriting from the `Shape` base class and providing their own implementations of the `Draw` method.

4. Write client code, such as the `DrawingService` class, that operates on the abstract base class, allowing for the inclusion of any shape that adheres to the `Shape` contract.

Example 2: Payment Gateway

public abstract class PaymentGateway

{

public abstract void ProcessPayment(decimal amount);

}

public class PayPalGateway : PaymentGateway

{

public override void ProcessPayment(decimal amount)

{

// Implementation for processing payment using PayPal

}

}

public class StripeGateway : PaymentGateway

{

public override void ProcessPayment(decimal amount)

{

// Implementation for processing payment using Stripe

}

}

// Client code

public class PaymentService

{

private readonly PaymentGateway _gateway;

public PaymentService(PaymentGateway gateway)

{

_gateway = gateway;

}

public void ProcessPayment(decimal amount)

{

_gateway.ProcessPayment(amount);

}

}

Step-by-Step Guide:

1. Identify the behavior that you want to extend without modifying existing code. In this example, it is the ability to process payments using different payment gateways.

2. Create an abstract base class, `PaymentGateway`, that defines a contract for all payment gateways.

3. Implement specific payment gateways, such as `PayPalGateway` and `StripeGateway`, by inheriting from the `PaymentGateway` base class and providing their own implementations of the `ProcessPayment` method.

4. Write client code, such as the `PaymentService` class, that depends on the abstract base class, allowing for the use of any payment gateway that adheres to the `PaymentGateway` contract.

Example 3: Report Generation

public abstract class ReportGenerator

{

public abstract void GenerateReport();

}

public class PdfReportGenerator : ReportGenerator

{

public override void GenerateReport()

{

// Implementation for generating a PDF report

}

}

public class ExcelReportGenerator : ReportGenerator

{

public override void GenerateReport()

{

// Implementation for generating an Excel report

}

}

// Client code

public class ReportService

{

private readonly ReportGenerator _generator;

public ReportService(ReportGenerator generator)

{

_generator = generator;

}

public void GenerateReport()

{

_generator.GenerateReport();

}

}

Step-by-Step Guide:

1. Identify the behavior that you want to extend without modifying existing code. In this example, it is the ability to generate different types of reports.

2. Create an abstract base class, `ReportGenerator`, that defines a contract for all report generators.

3. Implement specific report generators, such as `PdfReportGenerator` and `ExcelReportGenerator`, by inheriting from the `ReportGenerator` base class and providing their own implementations of the `GenerateReport` method.

4. Write client code, such as the `ReportService` class, that relies on the abstract base class, enabling the generation of any report type that adheres to the `ReportGenerator` contract.

The Open/Closed Principle

By following the Open/Closed Principle, we design systems that are more modular, extensible, and resistant to changes. The use of abstraction and inheritance allows us to add new functionalities without modifying existing code, resulting in a codebase that is easier to maintain, test, and extend over time.

The Liskov Substitution Principle (LSP)

Liskov Substitution Principle (LSP) is one of the SOLID principles in software development. It states that objects of a superclass should be substitutable with objects of its subclasses without affecting the correctness of the program. In other words, if a program is designed to work with a certain type, it should be able to work with any subtype of that type without unexpected behavior or breaking the program’s logic.

The LSP promotes the idea of substitutability and contract compliance. It ensures that derived classes adhere to the same interface and behavioral expectations as their base classes. By following LSP, we create code that is more flexible, modular, and allows for easier maintenance and evolution.

Now, let’s explore three different C# code examples that demonstrate the application of Liskov Substitution Principle:

Example 1: Animal Feeding

public abstract class Animal

{

public abstract void Feed();

}

public class Cat : Animal

{

public override void Feed()

{

// Implementation for feeding a cat

}

}

public class Dog : Animal

{

public override void Feed()

{

// Implementation for feeding a dog

}

}

// Client code

public class AnimalFeeder

{

public void FeedAnimals(IEnumerable<Animal> animals)

{

foreach (var animal in animals)

{

animal.Feed();

}

}

}

Step-by-Step Guide:

1. Identify the base class and its derived classes. In this example, the base class is `Animal`, and the derived classes are `Cat` and `Dog`.

2. Ensure that the derived classes adhere to the same interface and contract as the base class. Each derived class must override the `Feed` method defined in the base class.

3. Write client code, such as the `AnimalFeeder` class, that operates on the base class but can work with any derived class without issues.

Example 2: Shape Calculation

public abstract class Shape

{

public abstract double CalculateArea();

}

public class Rectangle : Shape

{

public double Width { get; set; }

public double Height { get; set; }

public override double CalculateArea()

{

return Width * Height;

}

}

public class Circle : Shape

{

public double Radius { get; set; }

public override double CalculateArea()

{

return Math.PI * Math.Pow(Radius, 2);

}

}

// Client code

public class AreaCalculator

{

public double CalculateTotalArea(IEnumerable<Shape> shapes)

{

double totalArea = 0;

foreach (var shape in shapes)

{

totalArea += shape.CalculateArea();

}

return totalArea;

}

}

Step-by-Step Guide:

1. Identify the base class and its derived classes. In this example, the base class is `Shape`, and the derived classes are `Rectangle` and `Circle`.

2. Ensure that the derived classes adhere to the same interface and contract as the base class. Each derived class must override the `CalculateArea` method defined in the base class.

3. Write client code, such as the `AreaCalculator` class, that operates on the base class but can work with any derived class without issues.

Example 3: File Storage

public abstract class FileStorage

{

public abstract void SaveFile(string filePath);

public abstract void DeleteFile(string filePath);

}

public class LocalFileStorage : FileStorage

{

public override void SaveFile(string filePath)

{

// Implementation for saving file locally

}

public override void DeleteFile(string filePath)

{

// Implementation for deleting file locally

}

}

public class CloudFile

Storage : FileStorage

{

public override void SaveFile(string filePath)

{

// Implementation for saving file to the cloud

}

public override void DeleteFile(string filePath)

{

// Implementation for deleting file from the cloud

}

}

// Client code

public class FileManager

{

private readonly FileStorage _fileStorage;

public FileManager(FileStorage fileStorage)

{

_fileStorage = fileStorage;

}

public void SaveAndDeleteFile(string filePath)

{

_fileStorage.SaveFile(filePath);

// Perform other operations

_fileStorage.DeleteFile(filePath);

}

}

Step-by-Step Guide:

1. Identify the base class and its derived classes. In this example, the base class is `FileStorage`, and the derived classes are `LocalFileStorage` and `CloudFileStorage`.

2. Ensure that the derived classes adhere to the same interface and contract as the base class. Each derived class must override the `SaveFile` and `DeleteFile` methods defined in the base class.

3. Write client code, such as the `FileManager` class, that operates on the base class but can work with any derived class without issues.

By following Liskov Substitution Principle, we ensure that derived classes can be used interchangeably with their base class without introducing unexpected behavior or breaking the program’s logic. This principle promotes flexibility, modularity, and code reusability, resulting in more maintainable and extensible software systems.

The Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is one of the SOLID principles in software development. It states that clients should not be forced to depend on interfaces they do not use. The principle emphasizes the importance of designing cohesive and focused interfaces that are tailored to the specific needs of clients.

By adhering to the ISP, we ensure that interfaces are small, specific, and do not contain unnecessary methods or dependencies. This leads to code that is more maintainable, extensible, and allows for better reusability of components.

Now, let’s explore three different C# code examples that demonstrate the application of the Interface Segregation Principle:

Example 1: Printer Interface

public interface IPrinter

{

void Print();

}

public interface IScanner

{

void Scan();

}

public class AllInOnePrinter : IPrinter, IScanner

{

public void Print()

{

// Implementation for printing

}

public void Scan()

{

// Implementation for scanning

}

}

Step-by-Step Guide:

1. Identify the interface that needs to be segregated. In this example, it is the printer-related functionality.

2. Split the interface into smaller, more focused interfaces. In this case, we create separate `IPrinter` and `IScanner` interfaces.

3. Implement classes that only depend on the specific interfaces they require. The `AllInOnePrinter` class implements both `IPrinter` and `IScanner`, but other classes can implement only the interface(s) they need.

Example 2: Payment Gateway Interface

public interface IPaymentProcessor

{

void ProcessPayment(decimal amount);

}

public interface IRecurringPaymentProcessor

{

void ProcessRecurringPayment(decimal amount);

}

public class PayPalProcessor : IPaymentProcessor, IRecurringPaymentProcessor

{

public void ProcessPayment(decimal amount)

{

// Implementation for processing a payment via PayPal

}

public void ProcessRecurringPayment(decimal amount)

{

// Implementation for processing a recurring payment via PayPal

}

}

Step-by-Step Guide:

1. Identify the interface that needs to be segregated. In this example, it is the payment processing functionality.

2. Split the interface into smaller, more focused interfaces. Here, we create separate `IPaymentProcessor` and `IRecurringPaymentProcessor` interfaces.

3. Implement classes that only depend on the specific interfaces they require. The `PayPalProcessor` class implements both `IPaymentProcessor` and `IRecurringPaymentProcessor`, but other classes can implement only the interface(s) they need.

Example 3: File Storage Interface

public interface IFileStorage

{

void SaveFile(string filePath);

void DeleteFile(string filePath);

}

public interface ICloudStorage : IFileStorage

{

void BackupFile(string filePath);

}

public class AzureStorage : ICloudStorage

{

public void SaveFile(string filePath)

{

// Implementation for saving file to Azure storage

}

public void DeleteFile(string filePath)

{

// Implementation for deleting file from Azure storage

}

public void BackupFile(string filePath)

{

// Implementation for backing up file in Azure storage

}

}

Step-by-Step Guide:

1. Identify the interface that needs to be segregated. In this example, it is the file storage functionality.

2. Split the interface into smaller, more focused interfaces. Here, we create separate `IFileStorage` and `ICloudStorage` interfaces, where `ICloudStorage` inherits from `IFileStorage`.

3. Implement classes that only depend on the specific interfaces they require. The `AzureStorage` class implements both `IFile

Storage` and `ICloudStorage`, but other classes can implement only the interface(s) they need.

By following the Interface Segregation Principle, we design interfaces that are focused, cohesive, and tailored to specific client needs. This leads to code that is more modular, maintainable, and allows for better reusability of components. Additionally, it helps avoid unnecessary dependencies and ensures that clients are not burdened with methods they don’t need or use.

The Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) is one of the SOLID principles in software development. It states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Furthermore, it states that abstractions should not depend on details, but details should depend on abstractions. The principle promotes loose coupling, modularity, and flexibility in the design of software systems.

By adhering to the DIP, we decouple modules, reduce dependencies, and make our code more maintainable, testable, and extensible. It allows for easier substitution of components and facilitates the application of other design patterns such as Dependency Injection.

Now, let’s explore three different C# code examples that demonstrate the application of the Dependency Inversion Principle:

Example 1: Messaging System

public interface IMessageSender

{

void SendMessage(string message);

}

public class EmailSender : IMessageSender

{

public void SendMessage(string message)

{

// Implementation for sending an email

}

}

public class SmsSender : IMessageSender

{

public void SendMessage(string message)

{

// Implementation for sending an SMS

}

}

public class NotificationService

{

private readonly IMessageSender _messageSender;

public NotificationService(IMessageSender messageSender)

{

_messageSender = messageSender;

}

public void SendNotification(string message)

{

_messageSender.SendMessage(message);

}

}

Step-by-Step Guide:

1. Identify the high-level module (`NotificationService`) and the low-level modules (`EmailSender` and `SmsSender`).

2. Create an abstraction (`IMessageSender`) that both the high-level and low-level modules depend on.

3. Modify the high-level module to depend on the abstraction (`IMessageSender`) instead of the concrete implementations (`EmailSender` and `SmsSender`).

4. Use dependency injection or a similar technique to provide the specific implementation (`EmailSender` or `SmsSender`) to the high-level module at runtime.

Example 2: File Logger

public interface ILogger

{

void Log(string message);

}

public class FileLogger : ILogger

{

public void Log(string message)

{

// Implementation for logging to a file

}

}

public class DatabaseLogger : ILogger

{

public void Log(string message)

{

// Implementation for logging to a database

}

}

public class LogManager

{

private readonly ILogger _logger;

public LogManager(ILogger logger)

{

_logger = logger;

}

public void LogMessage(string message)

{

_logger.Log(message);

}

}

Step-by-Step Guide:

1. Identify the high-level module (`LogManager`) and the low-level modules (`FileLogger` and `DatabaseLogger`).

2. Create an abstraction (`ILogger`) that both the high-level and low-level modules depend on.

3. Modify the high-level module to depend on the abstraction (`ILogger`) instead of the concrete implementations (`FileLogger` and `DatabaseLogger`).

4. Use dependency injection or a similar technique to provide the specific implementation (`FileLogger` or `DatabaseLogger`) to the high-level module at runtime.

Example 3: Data Access Layer

public interface IDataAccessLayer

{

void SaveData(string data);

}

public class SqlDataAccessLayer : IDataAccessLayer

{

public void SaveData(string data)

{

// Implementation for saving data to a SQL database

}

}

public class FileDataAccessLayer : IDataAccessLayer

{

public void SaveData(string data)

{

// Implementation for saving data to a file

}

}

public class DataProcessor

{

private readonly

IDataAccessLayer _dataAccessLayer;

public DataProcessor(IDataAccessLayer dataAccessLayer)

{

_dataAccessLayer = dataAccessLayer;

}

public void ProcessData(string data)

{

_dataAccessLayer.SaveData(data);

}

}

Step-by-Step Guide:

1. Identify the high-level module (`DataProcessor`) and the low-level modules (`SqlDataAccessLayer` and `FileDataAccessLayer`).

2. Create an abstraction (`IDataAccessLayer`) that both the high-level and low-level modules depend on.

3. Modify the high-level module to depend on the abstraction (`IDataAccessLayer`) instead of the concrete implementations (`SqlDataAccessLayer` and `FileDataAccessLayer`).

4. Use dependency injection or a similar technique to provide the specific implementation (`SqlDataAccessLayer` or `FileDataAccessLayer`) to the high-level module at runtime.

By following the Dependency Inversion Principle, we design our software to depend on abstractions rather than concrete implementations. This leads to loosely coupled and modular code, which is easier to maintain, test, and extend. Additionally, it enables better code reuse and facilitates the application of various design patterns that rely on inversion of dependencies, such as Dependency Injection.

Ah, the power of SOLID principles! Embracing these principles is like unlocking a treasure trove of benefits for our codebase. By applying these sacred principles, we transcend the ordinary and elevate our creations to new heights.

Imagine a codebase that is not only reusable, but also a joy to maintain. A codebase that effortlessly scales with our ever-growing needs. A codebase that invites testing with open arms, making it a breeze to ensure the highest quality.

SOLID principles are the key to unlocking this extraordinary world. They empower us to craft software that defies the limitations of the mundane. Each principle, like a guiding star, lights our path towards a codebase that is a marvel of reusability, maintainability, scalability, and testability.

But it doesn’t end there. When we embrace SOLID principles, we become architects of greatness. We create software that is a testament to our craftsmanship and dedication. Our creations transcend the ordinary and leave a lasting impact on those who interact with them.

So, my fellow developers, let us embark on this transformative journey. Let us breathe life into our applications with SOLID principles as our guiding light. Together, we will build a codebase that is not just functional, but awe-inspiring. A codebase that stands the test of time and sets new standards in the realm of software development.

Dare to dream big, for SOLID principles will pave the way to greatness. Embrace them with passion, and witness the magic unfold in your applications. The world is waiting for the extraordinary, and you have the power to make it happen.

--

--

Sande Satoskar

A coffee absorber. a student of positivity who believes that to err is human and to arr is pirate!