SOLID principles in C# used in object-oriented design

Published: Monday 19 September 2022

Using the SOLID principles in C# is important for the design of a .NET application.

Its aim is to reduce dependencies, so one area can be changed, without affecting others.

SOLID is an acronym in object-oriented design (OOD), with each letter representing a design principle. We will go through each one, find out what they are, and demonstrate an example.

Single-responsibility principle

The single-responsibility principle (or SRP) states that there should never be more than one reason for a class to change.

That means that every class should have a single responsibility.

Take this TeamService class:

public class Team { 
}
public class TeamService {
	public void Create(Team team) { }
	 
	public void Update(int id, Team team) { }

	public void Delete(int id) { }

	public void AddToCompetition(int teamId, int competitionId) { }
}

The CreateUpdate and Delete methods are fine in this instance, as they are directly related to the team.

However, the AddToCompetition is related more to the competition then it is to the team.

What if we want to add more competition methods to this interface? Maybe we want to the ability to read what competitions a team appears in? Or maybe, we want to know all the competitions that exist?

As the application develops, there is a risk that this class could include multiple methods that are not directly related to the team. As a result, the code will become unreadable and unmanageable.

Another problem with this approach is that the competition is dependent on the team. Just say we wanted to create a CricketTeamService that include members specific for a cricket team, but still inherits the main TeamService. In this instance, we would also have to include the competition methods for both the TeamService and CricketTeamService.

A much better solution in this example is to create a separate service for the competition methods.

That means that the team and competition are separate from each other.

public class Team { 
}
public class TeamService {
	public void Create(Team team) { }
	 
	public void Update(int id, Team team) { }

	public void Delete(int id) { }
}
public class CompetitionService {
	public void AddToCompetition(int teamId, int competitionId) { }
}

Open–closed principle

The open-closed principle (OCP) indicates that software entities should be open for extension but closed for modification.

In this statement, the "open for extension" part means that a class can add new functionality. "Closed for modification" means that the class should not be modified unless bugs are discovered.

In the following example, there are two classes. One to represent a car and one to represent a motorbike. Both classes can specify the number of wheels that are attached to it.

There is also another class called Wheel. This class counts the total number of wheels on all the vehicles.

public class Car {
	public int Wheels { get; set; }
}
 
public class Motorbike {
	public int Wheels { get; set; }
}
 
public class Wheel {
 
	public int CountTotalWheels(object[] vehicles) {
		 
		var wheelCount = 0;
		 
		foreach (var vehicle in vehicles) {        
			if (vehicle is Car) {
				wheelCount += ((Car)vehicle).Wheels;            
			}
			if (vehicle is Motorbike) {
				wheelCount += ((Motorbike)vehicle).Wheels;            
			}        
		}    
		 
		return wheelCount;
	}
}

The good thing about this example is that this is meeting the first SOLID principle. The single-responsibility principle. The Wheel class is separate from the different vehicle classes.

However, the CountTotalWheels method is not meeting the open-closed principle. It's not open for extension, as it can only handle cars and motorbikes without modification.

If we want to add a different vehicle like a bus, the CountTotalWheels method would have to be modified.

The solution is to move the properties to a base class. Both the Car and Motorbike have Wheels as properties. Let's move these properties to a Vehicle base class.

Afterwards, the Car and Motorbike inherit from the Vehicle class, and as a result, it will be able to inherit these base properties.

When calculating the total number of wheels, we can pass in an array of type Vehicle[] rather than type object[], making it type safe. In-addition, we can increment the wheel count, simply by calling the Wheels property.

public abstract class Vehicle {
	public int Wheels { get; set; }
}
 
public class Car : Vehicle {        
}
 
public class Motorbike : Vehicle {    
}
 
public class Wheel {
 
	public int CountTotalWheels(Vehicle[] vehicles) {
		 
		var wheelCount = 0;
		 
		foreach (var vehicle in vehicles) {    
			wheelCount += vehicle.Wheels;                    
		}    
		 
		return wheelCount;
	}
}

At a later stage, if we want to count the wheels of a different vehicle, we could create a new class that inherits the Vehicle base class. If this is the case, the CountTotalWheels method would not have to be modified, as it's already passing in an instance of Vehicle as it's parameter.

Liskov substitution principle

Liskov substitution principle (LSP) is how a child class behaves when it inherits a parent class.

An inherited class should not try to change the members of its parent class, or the way it behaves. The principle is being violated if one of the child class members throws an exception.

In the following classes, both the Car and Motorbike classes inherit from the Vehicle class.

public abstract class Vehicle {
	public int Wheels { get; set; }
	public virtual string? HelmetDesign { get; set; }
}
public class Car : Vehicle {
	public override string? HelmetDesign => throw new NotImplementedException();
}
public class Motorbike : Vehicle {
}

In the Vehicle class, there is a property named HelmetDesign. That means that any class inheriting the Vehicle class must include it.

This is fine in the Motorbike class as there's a requirement to wear a helmet. However, an exception is thrown for the Car class as it's not required.

The child Car class is breaking the parent Vehicle class because it's not returning a string for the HelmetDesign property. Therefore, it's violating the LSP principle.

The best way to resolve this is to remove the HelmetDesign property out of the Vehicle class, create a new IHelmet interface and add the property into there.

That means that the Motorbike class can implement IHelmet and include its members. As there is no requirement to wear a helmet in a car, the Car class just inherits the Vehicle class.

public abstract class Vehicle {
	public int Wheels { get; set; }
}
public interface IHelmet {
	string? HelmetDesign { get; set; }
}
public class Car : Vehicle {
}
public class Motorbike : Vehicle, IHelmet {
	public string? HelmetDesign { get; set; }
}

Interface segregation principle

The interface segregation principle (ISP) is when a class implements an interface where it requires all its members.

With C#, a class can inherit multiple interfaces. Therefore, it makes sense to break interfaces down so classes only implement interfaces that are related to them.

Take this example. The ITeam interface which both the CricketTeam and PoolTeam classes implement has a BallCount and WicketCount property.

public interface ITeam {    
	int BallCount { get; set; }    
	int WicketCount { get; set; }
}
public class CricketTeam : ITeam {
	public string? Name { get; set; }    
	public int BallCount { get; set; }
	public int WicketCount { get; set; }
}
public class PoolTeam : ITeam {
	public string? Name { get; set; }    
	public int BallCount { get; set; }
	public int WicketCount { get; set; }
}

Both cricket and pool have at least one ball, so the BallCount property is valid. However, the game of pool does not have wickets. Therefore, the WicketCount is not needed for this.

This violates ISP as the PoolTeam class is inheriting an interface where it doesn't require all the members.

To fix that, we can have a separate interface for the wicket. That means that each class only includes the properties that it requires.

public interface ITeam
{
	string? Name { get; set; }
	public int BallCount { get; set; }
}
public interface IWicket
{
	int WicketCount { get; set; }
}
public class CricketTeam : ITeam, IWicket
{
	public string? Name { get; set; }
	public int BallCount { get; set; }
	public int GoalCount { get; set; }
	public int WicketCount { get; set; }
}
public class PoolTeam : ITeam
{
	public string? Name { get; set; }
	public int BallCount { get; set; }
	public int GoalCount { get; set; }
}

Dependency inversion principle

The dependency inversion principle (DIP) states that high-level classes should not depend on low-level classes.

Just say we have a remote control and wish to use the same remote control on different TVs.

Or we lose the remote control. We can get a new remote control without replacing the TV.

Having this loose coupling between the TV and remote control means many we can replace one of them without necessary affecting the other.

In addition, this principle looks at abstractions rather than details. This means we want to know what a process is doing, and what the result will be.

Like pressing the "on" button on a remote control. The goal of this process is to turn the TV on. Not the specific details in achieving the goal.

public interface IRemoteControlService
{
	public void PressOnButton();
}
public interface ITVService
{
	public void TurnOn();
}
public class RemoteControlService : IRemoteControlService {
	private readonly ITVService _tvService;
	public RemoteControlService(ITVService tvService)
	{
		_tvService = tvService;
	}
	public void PressOnButton()
	{
		_tvService.TurnOn();
	}
}

ASP.NET Core has its own IoC container, which can be used for dependency injection.

Best practices involve registering a service and an interface to the IoC container. The interface then acts as an implementation and is passed into different parts of the application.

The benefits of this are they're not dependant on one another and they can be used for different purposes, such as test-driven development.

Watch the video

It is essential to understand the importance of the SOLID principles as a software developer.

Object-oriented programming (OOP) encourages writing code that is cleaner, more readable, and more scalable.

Watch our video where we explain each principle in more detail and demonstrate how they work.