Using the SOLID Principles in C#
The SOLID principles are a set of guidelines aimed at making software more maintainable and scalable.
Developing software is an ongoing endeavor of refactoring, bug fixing, and implementing new features. It can be frustrating and stressful, but most importantly, fun and exciting. To ensure that our software does not end up as a large bowl of spaghetti code, experts have come up with a series of design patterns and principles that we can use to create more maintainable and scalable code.
A set of these principles is called SOLID and was first introduced by Robert C. Martin (aka Uncle Bob) in his paper “Design Principles and Design Patterns” (2000) and later coined by Michael Feathers. While these principles can be very helpful, it is also important to recognize that we always have to make trade-offs when developing software. I therefore encourage anyone to be pragmatic by asking themselves if applying one or more of these principles to a piece of code will add value because applying the principles excessively might not lead to the desired outcome.
Let’s dive into the first principle. Inspired by my previous post on "Programming to an Interface", I’ll use pizza again as an example to illustrate the principles—because, really, who doesn’t love pizza?
Single Responsibility Principle (SRP)
A class should have only one reason to change.
The SRP states that a class should have only one reason to change, meaning it should encapsulate a single responsibility. If the class has more than one responsibility, then it becomes more likely that the class will need to change in the future. Changing the class will most likely impact our design, which can generate bugs and increase the testing burden.
It is probably worth pointing out that "Single Responsibility" does not necessarily mean that a class should have only one method. It is more about whether or not the class has high or low cohesion, which describes how well a class's methods, attributes, and behaviors are aligned toward a single responsibility. If all of these are aligned, then we say the class has high cohesion, while it has low cohesion when the opposite is true.
Example without the principle
Let us consider a PizzaStore class that enables customers to sign up, log in, order pizza, and have it delivered. At first glance, it looks alright, but the problem here is low cohesion, since the group of methods focuses on solving different things.
public class PizzaStore
{
public void Signup() {}
public void Login() {}
public void OrderPizza() {}
public void DeliverPizza(){}
}
Example with the principle
In order to create higher cohesion, we refactor our PizzaStore class by creating three separate classes, each with its own responsibility.
public class MemberService
{
public void Signup() {}
public void Login() {}
}
public class PizzaOrder
{
public void OrderPizza() {}
}
public class PizzaDelivery
{
public void Deliver(){}
}
Besides creating higher cohesion, it also becomes more evident what each class does. Furthermore, if we choose to implement a feature that allows customers to reset their password, then it is quite obvious that it should be grouped together with the other methods in the Membership class as opposed to the other classes.
Open-Closed Principle (OCP)
A class should be closed for modification, but open for extension.
The OCP states that a class should be closed for modification but open for extension, meaning we should be able to change the behavior of classes, methods, etc., but without modifying the existing code. If we allow modification of existing functionality, we risk not only introducing new bugs but also creating lower cohesion if the modifications involve new functionality.
Example without the principle
Continuing with our pizza theme, let us consider a PizzaOrder class that calculates the price of the pizza we are ordering. Right now, the method can calculate the price of three different pizzas, but at some point, we might want to expand our menu offering. In order to expand our menu, we would need to refactor our method to change its behavior, and that is where we break the OCP principle.
public class PizzaOrder
{
private PizzaType _pizzaType;
public PizzaOrder(PizzaType pizzaType){
_pizzaType = pizzaType;
}
public decimal CalculatePrice()
{
switch (_pizzaType)
{
case PizzaType.Pepperoni:
return 6.0m;
case PizzaType.Chesse:
return 4.0m;
case PizzaType.Vegan:
return 5.0m;
default:
throw new ArgumentOutOfRangeException();
}
}
}
While the above example works, it quickly becomes less maintainable as we scale our pizza store. Additionally, if we introduce a new pizza and forget to refactor our code, we may encounter an exception (and unhappy customers).
Example with the principle
One way to solve this challenge is to introduce an abstract Pizza class (or an interface) that existing and new concrete pizza classes can inherit from. Furthermore, we refactor our PizzaOrder class to inject our pizza object(s).
public abstract class Pizza
{
public abstract decimal CalculatePrice();
}
public class PepperoniPizza : Pizza
{
public override decimal CalculatePrice() => 6.0m;
}
public class CheesePizza : Pizza
{
public override decimal CalculatePrice() => 4.0m;
}
public class VeganPizza : Pizza
{
public override decimal CalculatePrice() => 5.0m;
}
public class PizzaOrder
{
private Pizza _pizza;
public PizzaOrder(Pizza pizza)
{
_pizza = pizza;
}
public decimal CalculatePrice() => _pizza.CalculatePrice();
}
PizzaOrder no longer cares about the type of pizza it needs to calculate the price for. As a result, we can now expand our pizza store with hundreds of types of pizzas without modifying our existing code.
On a side note, this is also an excellent example of how to refactor a switch-case statement into using polymorphism.
Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering the correctness of the program.
The LSP states that derived classes can be used interchangeably with their base types without altering the correctness of the program. This means that any object of a derived class should be able to replace an object of its base class without breaking the application’s functionality or introducing unexpected behavior.
Example without the principle
Let’s say we have a generic Pizza class with a method to add pepperoni. This works fine for pizzas with pepperoni, but if we create a VeganPizza class that inherits from Pizza, it forces us to implement the pepperoni method. Since vegan pizzas don’t have pepperoni, this method would either throw an exception or be unused, thereby violating the LSP.
public abstract class Pizza
{
protected Sauce sauce;
protected Pepperoni pepperoni;
protected IEnumerable<Veggies> veggies;
public abstract void AddPepperoni();
// Removed additional topping methods for clarity
}
public class PepperoniPizza : Pizza
{
public override void AddPepperoni()
{
pepperoni = new Pepperoni();
}
}
public class VeganPizza : Pizza
{
public override void AddPepperoni()
{
throw new NotSupportedException("No pepperoni on vegan pizza.");
}
}
Pizza pepperoniPizza = new PepperoniPizza();
pepperoniPizza.AddPepperoni(); // Adds pepperoni
Pizza veganPizza = new VeganPizza();
veganPizza.AddPepperoni(); // Throws an exception
Throwing an exception or having a method that does absolutely nothing is not something we are interested in. Let us move on to see how we can solve this.
Example with the principle
To resolve the problem, we refactor our abstract class to have a more appropriate method that supports object substitution. Instead of having a specialized method for adding toppings (e.g., pepperoni), we now have a Prepare method where we add the toppings as needed.
public abstract class Pizza
{
protected Sauce sauce;
protected Cheese cheese;
protected IEnumerable<Veggies> veggies;
public abstract void Prepare();
}
public class CheesePizza : Pizza
{
public override void Prepare()
{
sauce = new Sauce();
cheese = new Cheese();
}
}
public class VeganPizza : Pizza
{
public override void Prepare()
{
sauce = new Sauce();
veggies.Append(new Veggies());
veggies.Append(new Veggies());
}
}
With a refactored base class, we can now add endlessly more pizzas to our menu.
Interface Segregation Principle (ISP)
No client should be forced to depend on methods it does not use.
The ISP states that no client should be forced to depend on methods it does not use. In other words, ISP simply aims to split interfaces into smaller and more cohesive interfaces. That way, we ensure that classes only implement relevant methods.
Example of violating the principle
Consider the following example of an interface designed for creating a pizza. While this interface suits the PepperoniPizza class well, it is less effective for the CheesePizza class. This results in an interface with more methods than necessary, also known as interface bloat or a fat interface, which breaks the ISP.
public interface IPizza
{
void AddDough();
void AddSauce();
void AddCheese();
void AddPepperoni();
}
public class PepperoniPizza : IPizza
{
public void AddDough() {}
public void AddSauce() {}
public void AddCheese() {}
public void AddPepperoni() {}
}
public class CheesePizza : IPizza
{
public void AddDough() {}
public void AddSauce() {}
public void AddCheese() {}
public void AddPepperoni() { throw new NotImplementedException("No pepperoni on cheese pizza"); }
}
Furthermore, we also break the LSP, as seen in the previous example.
Example with the principle
So, in order to handle the challenge, we refactor our IPizza interface into two more cohesive interfaces. If we need to create a new type of pizza with cheese, we implement the ICheesePizza interface, and if we need a pizza with meat, we implement the IMeatPizza interface. If we need both cheese and meat, we implement both. However, if we also want to offer the Neapolitan-style Marinara pizza, then we should refactor the ICheesePizza interface accordingly.
public interface ICheesePizza
{
void AddDough();
void AddSauce();
void AddCheese();
}
public interface IMeatPizza : ICheesePizza
{
void AddMeat();
}
public class PepperoniPizza : IMeatPizza
{
public void AddDough() { }
public void AddSauce() { }
public void AddCheese() { /* Adding mozzarella */ }
public void AddMeat() { /* Adding pepperoni */ }
}
public class QuattroFormaggiPizza : ICheesePizza
{
public void AddDough() { }
public void AddSauce() { }
public void AddCheese() { /* Adding mozzarella, gorgonzola, parmigiano reggiano, goat cheese */ }
}
Great, we can now create several new pizzas that have different meat and cheese toppings.
Dependency Inversion Principle (DIP)
Depend upon Abstractions. Do not depend upon concretions.
The core idea of DIP is to reduce the dependency of high-level components on low-level components by introducing an abstraction layer between them. This means that high-level modules and low-level modules interact through interfaces or abstract classes, rather than directly, to ensure loose coupling between the two.
Example of violating the principle
Consider the following example, where the PizzaOrderController (high-level module) directly instantiates a PizzaOrderService (low-level module). Although the controller uses the IPizzaOrderService abstraction to hold the object, it is still highly dependent on the concrete PizzaOrderService class, thereby violating the DIP.
public class PizzaOrderController
{
private readonly IPizzaOrderService _pizzaOrderService;
public PizzaOrderController()
{
_pizzaOrderService = new PizzaOrderService();
}
public void OrderPizza(Pizza pizza)
{
_pizzaOrderService.Order(pizza);
}
}
Example with the principle
To remove the dependency on the concrete PizzaOrderService class, we need to refactor our code. Luckily, we have already created an abstraction of the class (i.e., our IPizzaOrderService), which makes our job a little easier. To remove the direct instantiation of PizzaOrderService, we have a few options, such as applying either the Dependency Injection or Factory Method pattern. Since this is ASP.NET Core and we are refactoring a controller, it makes sense to leverage Dependency Injection.
public class PizzaOrderController
{
private readonly IPizzaOrderService _pizzaOrderService;
public PizzaOrderController(IPizzaOrderService pizzaOrderService)
{
_pizzaOrderService = pizzaOrderService;
}
public void OrderPizza(Pizza pizza)
{
_pizzaOrderService.Order(pizza);
}
}
Excellent! We have now removed the dependency and made our design more loosely coupled.
Conclusion
The SOLID principles serve as foundational guidelines for writing clean, maintainable, and scalable code. A key takeaway from these principles is the importance of reducing dependencies on concrete classes. By relying on abstractions instead of specific implementations, we make our code more flexible, easier to extend, and less prone to breaking when changes are introduced.
Happy coding!