Logo
Published on

Applying SOLID Principles in .NET: A Guide to write Clean and Efficient Code

Authors
dotnet

Introduction

SOLID principles provide a great foundation in writing clean and maintainable code. It is very important for Software Developers to write code in such a way that it is understandable by other developers.

In this article, we'll explore how to apply the five SOLID principles especially in .NET, these principles can be applied on any programming language that supports object-oriented programming (OOP).

Let's dive into SOLID principles with examples of each principle.

Let's explore each of these principles in detail, starting with the Single Responsibility Principle (SRP).

1. Single Responsibility Principle

SRP states that a class should have only one reason to change. This principle helps keep your code organized and easier to manage.

Do just one thing and do it well.

Imagine you have a class called Reports that handles creating reports, saving them to a database, and sending them via email. But doing this way it violates the SRP. Why? Because one class is handling multiple responsibilities.

Now let's say in future you want to change your Database, then you have to change the Reports class, which might affect other operations as well.

We will seperate the Reports class into 3 seperate classes ReportGenerator for generating the report, DatabaseSaver to save the report to DB and EmailSender for sending the report view Email.

generator.cs
public class ReportGenerator
{
    public Report GenerateReport() {}
}

public class DatabaseSaver
{
    public void SaveReport(Report report) {}
}

public class EmailSender
{
    public void SendReport(Report report) {}
}

By doing this, each class now has a clear, singular purpose. If you need to change the database, you only need to modify the DatabaseSaver class, leaving the others untouched.

2. Open Closed Principle

The Open/Closed Principle (OCP) states that you can add new functionalities to your class but the existing code of the class should be untouched.

Open for extension but closed for modification.

Consider a scenario where you have a microservice responsible for processing orders in an e-commerce application. Initially, this service supports only credit card payments. You code will look something like this:-

Payments.cs
public class Payments
{
    public void CreditCardPaymentProcessor(Order order) { }
}

Later, you need to add support for PayPal payments. So you have to create a new Method in class Payments which will not be a good approach and it will violate OCP, instead you should extend the interface without altering its existing functionality.

  • Define an Interface for Payment Processing:
IPaymentProcessor.cs
public interface IPaymentProcessor
{
    void ProcessPayment(Order order);
}

  • Implement Specific Payment Processors:
Payments.cs
public class CreditCardPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(Order order) { }
}

public class PayPalPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(Order order) { }
}

This approach ensures that your order processing microservice remains open for extension (by adding new payment processors) but closed for modification (as the core processing logic doesn't change).

3. Liskov Substitution Principle

LSP states that superclass shall be replaceable with subclasses without breaking the application. In simpler terms a parent class(superclass) can be replaced by any of it's child class(subclass) without causing problems in your program.

Parent class can be replaced by subclass.

Let's take an example of Payments reference we gave in Open Closed Principle (OCP) . Let's create a class as PaymentProcess with a virtual function ProcessPayment.

PaymentProcessor.cs
class PaymentProcessor {
    public virtual void ProcessPayment(decimal amount) {
        Console.WriteLine($"Processing payment of ${amount}");
    }
}

Now let's create 2 subclasses of the PaymentProcessor Class.

PaymentProcessor.cs
class CreditCardPaymentProcessor : PaymentProcessor {
    public override void ProcessPayment(decimal amount) {
        Console.WriteLine($"Processing credit card payment of ${amount}");
    }
}

class PayPalPaymentProcessor : PaymentProcessor {
    public override void ProcessPayment(decimal amount) {
        Console.WriteLine($"Processing PayPal payment of ${amount}");
    }
}

According to the Liskov Substitution Principle, parent class PaymentProcessor should be able to replace any subclasses (PayPalPaymentProcessor).

First we will create an object of PaymentProcessor class and then we will update the object with the subclass of the parent class.

Payments.cs
PaymentProcessor processor = new PaymentProcessor();
processor.ProcessPayment(100.23);

processor = new PayPalPaymentProcessor();
processor.ProcessPayment(150.11); // Processes PayPal payment

As you can see we were able to replace the Parent Class with the Subclass without breaking our application. Whenever we want to make a Payment we can use PayPalPaymentProcessor class. Hence PayPalPaymentProcessor adhere to the Liskov Substitution Principle.

4. Interface Segregation Principle

ISP states that a class shouldn't implement those interfaces which are not required/useful for that class. Unnecessary implementation of interfaces should be avoided.

Create small and specific Interfaces.

Now let's assume we are creating different types of workers for different types of tasks in our factory. So we will create a base interface as IWorker.

IWorker.cs
public interface IWorker
{
    void Work();
    void Eat();
}

A class Robot implementing this interface (IWorker) would have unnecessary method like Eat.

Robot.cs
public class Robot : IWorker
{
    public void Work() { }

    public void Eat()
    {
        throw new NotImplementedException();
    }

}

So as you can see a Robot class is forced to implement unnecessary/unused methods. This is not a good approach and it also violates Interface Segregation Principle. Now let's look the correct way.

IWorker.cs
public interface IWork
{
    void Work();
}

public interface IEat
{
    void Eat();
}

Each interface now has a clear, singular purpose. Looks similar to - Single Responsibility Principle (SRP) , right?. Now we can optimise our Robot class.

Robot.cs
public class Robot : IWork
{
    public void Work()
    {
        // Robot working logic
    }
}

Classes implement only the methods they need and smaller interfaces lead to simpler and more manageable/understandable code.

5. Dependency Inversion Principle

DIP states that when different components interact with each other, instead of having specific implementations, we should use abstraction. This helps in making our code more flexible and easier to change as we can can switch out implementations without affecting other parts of the system.

Use abstraction instead of specific implementation.

Let's assume we are building an application and we want to send notifications through various platforms like email and SMS.

So we will create something like this:

Notifications.cs
public interface INotificationService {
    void SendNotification(string message);
}

public class EmailNotificationService : INotificationService {
    public void SendNotification(string message) {
        Console.WriteLine($"Sending email notification: {message}");
    }
}

public class SMSNotificationService : INotificationService {
    public void SendNotification(string message) {
        Console.WriteLine($"Sending SMS notification: {message}");
    }
}

Now we can create objects of each class to send notifications but there will be no abstraction. Standard method for Email notification and SMS notification will look something like this:-

EmailNotificationService emailSevice = new EmailNotificationService();
emailService.SendNotification("Order processed successfully");

SMSNotificationService smsService = new SMSNotificationService();
smsService.SendNotification("Order processed successfully");

To have abstraction we can create a NotificationManager class

NotificationManager.cs
public class NotificationManager
{
    private readonly INotificationService _notificationService;

    public NotificationManager(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }

    public void Notify(string message)
    {
        _notificationService.SendNotification(message);
    }
}

If we want to send an Email Notification our code will look something like this:

Notifications.cs
INotificationService emailService = new EmailNotificationService();
NotificationManager emailNotificationManager = new NotificationManager(emailService);
emailNotificationManager.Notify("Order processed successfully");

Now you must be wondering that why we did this? Why we created NotificationManager class we could have directly created an object of EmailNotificationService class.

Here are some points why creating a NotificationManager class was a good approach.

  • Separation of Concerns: The management of different notification services are now seperated from the main logic, providing abstraction.
  • Maintainability: In future if you want to add other notifications service(s) you just have to implement INotificationService with NotificationManager.
  • Flexibility: Without changing the main business logic you can easily switch between different notification services.

Conclusion

Understanding and applying these principle will help you write efficient code and also it will improve the code quality of your application which makes it easier to extend and modify in future.