Implementing SOLID principles in C#

Vishal Kamath
8 min readDec 19, 2023

--

Hello readers, Ever felt like your code resembles a Jenga tower, shaking at the brink of collapse with each new feature? As developers we have all have heard about the SOLID design principles, but very few people truly understand them.

SOLID is a design principle that was coined by Robert C. Martin. If you are working with an Object Oriented language these principle will help you write clean, scalable and maintainable code.

If your new here and are interested in Software Development please consider following me, I try to post weekly 😊.

SOLID stands for
Single-responsibility Principle
Open-closed Principle
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion Principle

For teaching these principles we will be using C# as our programming language.

Single Responsibility Principle

As the name suggest your classes and methods should have a single responsibility, in other words we want classes and methods to have high cohesion.

The goal is to have components that are independent, with a single well defined purposes.

Ideally a class should have only one reason to change, giving the class multiple responsibilities gives us multiple reasons to modify the class.

The more responsibilities a class has, the harder it is to maintain, test and extend it and your more likely to introduce bugs 🪲 in your codebase.

Example

We have created a Developer class, that takes on the role of storing the developer information as well as reading, writing and testing the codebase.

class Developer
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }

public string ReadCode()
{
// ...
return "Reading code...";
}

public void WriteCode(string updatedCode)
{
// ...
}

public void TestCode()
{
// ...
}
}

This violates our Single Responsibility Principle. In future, when our team of developers will work on different features of the Developer class, they will all end up updating the same file, this could lead to merge conflicts⚠️.

separating your classes into single responsibilities also adds the benefit of reusability in your code,

We can solve the above problem, by breaking them into services with help of extension methods, with the added benefit that these methods can also be implemented by other sub-classes.

class Developer
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }

public Developer(int id, string firstName, string lastName)
{
Id = id;
FirstName = firstName;
LastName = lastName;
}
}

static class CodebaseServices
{
public static string ReadCode(this Developer devloper)
{
// ...
return "Reading code...";
}

public static void WriteCode(this Developer devloper, string updatedCode)
{
// ...
}

public static void TestCode(this Developer devloper)
{
// ...
}
}

class Program
{
static void Main(string[] args)
{
Developer dev = new Developer(1, "Vishal", "Kamath");

dev.WriteCode("Hello World");
Console.WriteLine(dev.ReadCode());
}
}

NOTE: Having single responsibility doesn’t mean your class should do only one thing, it means that all the things it does should be very closely related.

Open-Closed Principle

Open-closed principle states that

software entities (classes, modules, functions, etc.) should be “Open” for extension but “Closed” for modification.

If you want to add a functionality to your class you shouldn’t be updating existing classes, as this could trigger a domino effect and break the processes that rely on your class.

So how do we add functionalities to theses classes, to do that you can use Inheritance to our advantage, instead of adding new code to our existing classes we create a new class that extends our functionalities

Example

We are working on a E-commerce application. We are tasked to implement a payment service that takes in payment by credit card or PayPal. We implement the following logic.

class PaymentService
{
public void ProcessPayment(string paymentType, int amount)
{
if (paymentType == "CreditCard")
{
// Process Credit Card Payment
Console.WriteLine("Processing Credit Card Payment");
}
else if (paymentType == "PayPal")
{
// Process PayPal Payment
Console.WriteLine("Processing PayPal Payment");
}
}
}

class Program
{
public static void Main(string[] args)
{
PaymentService paymentService = new PaymentService();
paymentService.ProcessPayment("CreditCard", 100);
}
}

Our clients now wants us to extend our functionality by supporting Google Pay as well.

public void ProcessPayment(string paymentType, int amount)
{
if (paymentType == "CreditCard")
{
// Process Credit Card Payment
Console.WriteLine("Processing Credit Card Payment");
}
else if (paymentType == "PayPal")
{
// Process PayPal Payment
Console.WriteLine("Processing PayPal Payment");
}
else if (paymentType == "GooglePay")
{
// Process Google Pay Payment
Console.WriteLine("Processing Google Pay Payment");
}
}

In future we will also need to implement additional functionality by adding Crypto payments as well. By now you can see that our code is starting to become more and more complicated.

The solution to the problem is that instead of modifying the original code, we create a base interface which can be extended by the different payment classes. Then create a payment service class which performs the transaction based on the payment type.

public interface IPaymentMethod
{
void ProcessPayment(double amount);
}

public class CreditCardPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
// payment logic
Console.WriteLine($"Processing credit card payment for amount: {amount}");
}
}

public class PayPalPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
// payment logic
Console.WriteLine($"Processing PayPal payment for amount: {amount}");
}
}

public class GooglePayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
// payment logic
Console.WriteLine($"Processing Google Pay payment for amount: {amount}");
}
}

public class CryptoPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
// payment logic
Console.WriteLine($"Processing cryptocurrency payment for amount: {amount}");
}
}

public class PaymentService
{
public void ProcessPayment(IPaymentMethod paymentMethod, double amount)
{
paymentMethod.ProcessPayment(amount);
}
}

class Program
{
static void Main(string[] args)
{
PaymentService paymentService = new PaymentService();

// credit
IPaymentMethod creditCardPayment = new CreditCardPayment();
paymentService.ProcessPayment(creditCardPayment, 100.0);

//PayPal
IPaymentMethod payPalPayment = new PayPalPayment();
paymentService.ProcessPayment(payPalPayment, 50.0);

// google pay
IPaymentMethod googlePayment = new GooglePayment();
paymentService.ProcessPayment(googlePayment, 50.0);

// cryptocurrency
IPaymentMethod cryptoPayment = new CryptoPayment();
paymentService.ProcessPayment(cryptoPayment, 200.0);
}
}

This way we don’t have to touch the existing code, we can simple add a new payment class and extend our services.

Liskov Substitution Principle

The Liskov Substitution principle states that,

If there are objects in your programming, you should be able to able to replace those objects with instances of their sub-types of sub-classes without affecting the programming.

To put it simply the child class should be able to do everything its parents can. Lets see that in action.

class Log
{
public virtual void Log(string message)
{
Console.WriteLine("Logging message");
Console.WriteLine(message);
}
}

class LogIntoFile : Log
{
public override void Log(string message)
{
Console.WriteLine("Logging message to file");
Console.WriteLine(message);
}
}

class LoggerMethod
{
public void Log(Log log, string message)
{
log.Log(message);
}
}

class Program
{
public static void Main(string[] args)
{
LoggerMethod loggerMethod = new LoggerMethod();
loggerMethod.Log(new Log(), "Hello World");
loggerMethod.Log(new LogIntoFile(), "Hello World");
}
}

based on the above code you can switch functionalities based on your requirements without damaging the correctness of the code.

Interface Segregation Principle

Interfaces are a contract that your classes need to implement. If you have a bulky interface then your classes are forced to implement all of the methods it specifies, even the ones they might not end up using. That’s why the Interface Segregation Principle states

An Entity should not be forced to depend on methods it does not use

Example

We have a Developer class that implements 2 interfaces IEmployee and ICodePrivileges.

interface IEmployee
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}

interface ICodePrivileges
{
public void WriteCode(string updatedCode);
public string ReadCode();
public void TestCode();
}

class Developer: IEmployee, ICodePrivileges
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }

public void WriteCode(string updatedCode)
{
Console.WriteLine($"Writing code: {updatedCode}");
}

public string ReadCode()
{
return "Reading code";
}

public void TestCode()
{
Console.WriteLine("Testing code");
}

public Developer(int id, string firstName, string lastName)
{
Id = id;
FirstName = firstName;
LastName = lastName;
}
}

Now, we want to create a new Tester class that should only be able to read and test code.

class Tester: IEmployee, ICodePrivileges
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }

public void WriteCode(string updatedCode)
{
throw new InvalidOperationException("Testers are not allowed to use this method.");
}

public string ReadCode()
{
return "Reading code";
}

public void TestCode()
{
Console.WriteLine("Testing code");
}

public Tester(int id, string firstName, string lastName)
{
Id = id;
FirstName = firstName;
LastName = lastName;
}
}

As you can see, even thought the tester class should not be able to write code it is forced to implement it with an error to prevent access. This breaks our principle, classes should not be exposed to attributes or be able to implement methods that are not required for them.

To solve this you break the interface down into smaller interfaces that can be implemented as per your needs. we can implement the following 4 interfaces to define them both.

interface IEmployee
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}

interface IWritePrivilege
{
public void WriteCode(string updatedCode);
}

interface IReadPrivilege
{
public string ReadCode();
}

interface ITestCode
{
public void TestCode();
}

class Developer : IEmployee, IWritePrivilege, IReadPrivilege, ITestCode
{
// implement developer code
}

class Tester: IEmployee, IReadPrivilege, ITestCode
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }

public string ReadCode()
{
return "Reading code...";
}

public void TestCode()
{
Console.WriteLine("Testing code...");
}
}

Dependency Inversion Principle

Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions.

This principle helps us decouple our code from relying on concrete dependencies.

Example

we have a high-level module BusinessLogic that directly depends on a low-level module DatabaseService.

public class DatabaseService
{
public void SaveData(string data)
{
// Save data to the database
}
}

public class BusinessLogic
{
private DatabaseService databaseService;

public BusinessLogic()
{
this.databaseService = new DatabaseService();
}

public void ProcessData(string data)
{
// Process the data
databaseService.SaveData(data);
}
}

Now, let’s say while testing we want to switch databases to a development environment. In a dev environment, we want the database to have added functionality of not just storing the data but saving the timestamp along with it as well. This will help us in tracking the flow of our application.

But, since BusinessLogic is dependent on a low-level module, it is forced to implement the logic of the DatabaseService.

We can solve this problem by introducing abstraction between the classes.

public interface IDataService
{
void SaveData(string data);
}

public class DatabaseService : IDataService
{
public void SaveData(string data)
{
// Save data to the database
}
}

public class DevDatabaseService : IDataService
{
public void SaveData(string data)
{
// Save data and the timestamp to the database
}
}

public class BusinessLogic
{
private IDataService dataService;

public BusinessLogic(IDataService dataService)
{
this.dataService = dataService;
}

public void ProcessData(string data)
{
// Process the data
dataService.SaveData(data);
}
}

public class Program
{
public static void Main(string[] args)
{
var businessLogic = new BusinessLogic(new DatabaseService());
businessLogic.ProcessData("Some data");

var businessLogic2 = new BusinessLogic(new DevDatabaseService());
businessLogic2.ProcessData("Some data");
}
}

By doing so, we have separated BusinessLogic from relying on the implementation of DatabaseService .

In an every changing landscape of Software development, SOLID principles help us better structure our applications. By following the guidelines developers can architect there code into being maintainable, scalable and reliable.

Thanks for reading my blog!! Leave a clap if you enjoy 👏

Follow my socials to stay updated on anything new I have to tell😊.

--

--