SOLID principles in C#: How to write clean, flexible, and reusable code
In software development, code maintenance is like a never-ending marathon — it takes endurance, patience, and a lot of coffee, and I mean a LOT of COFFEE ☕️️️. So if you are tired of your codebase being a hot mess then grab your favorite coding beverage and get ready to SOLID-ify your codebase like a boss.
So, what are SOLID principles 🤔
No, it’s not an acronym for a superhero team, although it might as well be considering how much it can save your code, because trust me, your codebase will thank you so will your coworkers.
SOLID principles are a set of design principles for writing clean, maintainable and extensible code. These principles were introduced by Robert C. Martin (also known as Uncle Bob) to help developers create code that is easy to read, understand, and maintain. The SOLID principles are made up of five principles, namely:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Throughout the article, we will dive into each of the SOLID principles and explore their practical applications using none other than the beloved C# programming language. We will examine both effective and flawed coding implementations to provide a comprehensive understanding of each principle.
Single Responsibility Principle (SRP):
The SRP states that a class should have only one reason to change. This means that a class should have only one responsibility, and any change to that responsibility should only affect that class. To illustrate this, let’s look at an example of a class that violates the SRP.
// Bad approach
class Customer
{
public void AddCustomer()
{
//Add customer to database
}
public void SendEmail()
{
//Send email to customer
}
public void GenerateInvoice()
{
//Generate invoice for customer
}
}
In the above example, the Customer
class has multiple responsibilities, such as adding a customer to the database, sending an email, and generating an invoice. This violates the SRP as any change to one of these responsibilities will affect the entire class. A better implementation would be to split the responsibilities into separate classes as shown below.
// Good approach - using SRP
class Customer
{
public void AddCustomer()
{
//Add customer to database
}
}
class EmailSender
{
public void SendEmail()
{
//Send email to customer
}
}
class InvoiceGenerator
{
public void GenerateInvoice()
{
//Generate invoice for customer
}
}
In this implementation, the responsibilities are separated into three different classes, each responsible for its own functionality. This makes the code more maintainable, extensible, and easier to understand.
Open-Closed Principle (OCP):
The OCP states that a class should be open for extension but closed for modification. This means that you should be able to add new functionality to a class without changing its existing code. So it is like a house party: you want to be open to letting new guests in, but you don’t want to be constantly changing the layout of the house to accommodate them.
Let’s take a look at an example of a class that violates the OCP.
// Bad approach
public class Superhero
{
public string Name { get; set; }
public string Power { get; set; }
public void UsePower()
{
if (Power == "Fly")
Console.WriteLine($"{Name} is flying!");
else if (Power == "Invisibility")
Console.WriteLine($"{Name} is invisible!");
else if (Power == "Super Strength")
Console.WriteLine($"{Name} is lifting a car!");
else
throw new Exception("Unknown superpower!");
}
}
public class Superhero
{
public string Name { get; set; }
public string Power { get; set; }
public void UsePower()
{
if (Power == "Fly")
Console.WriteLine($"{Name} is flying!");
else if (Power == "Invisibility")
Console.WriteLine($"{Name} is invisible!");
else if (Power == "Super Strength")
Console.WriteLine($"{Name} is lifting a car!");
else
throw new Exception("Unknown superpower!");
}
}
In this example, the Superhero
class violates the OCP because it is not closed for modification. If we want to add a new superpower, such as laser vision or teleportation, we would need to modify the UsePower
method.
Here’s how we can fix it to follow the OCP:
// Good approach - using OCP
public abstract class Superhero
{
public string Name { get; set; }
public abstract void UsePower();
}
public class FlyingSuperhero : Superhero
{
public override void UsePower()
{
Console.WriteLine($"{Name} is flying!");
}
}
public class InvisibilitySuperhero : Superhero
{
public override void UsePower()
{
Console.WriteLine($"{Name} is invisible!");
}
}
public class StrengthSuperhero : Superhero
{
public override void UsePower()
{
Console.WriteLine($"{Name} is lifting a car!");
}
}
Now we have separate classes for each type of superpower, each implementing their own UsePower
method. This allows us to add new superpowers without modifying the Superhero
class. Plus, we can imagine all sorts of funny superheroes with strange powers, like the ability to turn into a giant hamster! This idea could make a great movie, I have to copyright it, I mean, we already have a hero that transforms into an ant.
Liskov Substitution Principle (LSP):
The LSP states that a derived class should be able to replace its base class without affecting the correctness of the program. which is a fancy way of saying that If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck. To illustrate this, let’s look at an example.
public class Duck
{
public virtual void Quack()
{
Console.WriteLine("Quack quack!");
}
}
public class RubberDuck : Duck
{
public override void Quack()
{
Console.WriteLine("Squeak squeak!");
}
}
public class DecoyDuck : Duck
{
public override void Quack()
{
throw new NotImplementedException();
}
}
public void MakeDuckNoise(Duck duck)
{
duck.Quack();
}
// Usage
MakeDuckNoise(new Duck()); // Outputs "Quack quack!"
MakeDuckNoise(new RubberDuck()); // Outputs "Squeak squeak!"
MakeDuckNoise(new DecoyDuck()); // Oops! NotImplementedException exception
In this example, the DecoyDuck
class inherits from the Duck
class, but it overrides the Quack
method to throw a NotImplementedException
. This violates LSP because it means that a DecoyDuck
object cannot be used in the same way as a Duck
object. If code is written that assumes all Duck
objects can quack, it will crash when it encounters a DecoyDuck
object.
Interface Segregation Principle (ISP):
The ISP states that a client should not be forced to depend on methods it does not use. In other words, interfaces should be tailored to the specific needs of the clients that use them, rather than being a “one-size-fits-all” solution.
To illustrate this, Imagine we’re creating a superhero simulation game in C#. We have a Superhero interface that all superheroes in the game must implement, which includes methods like Fly
, Fight
, and UseSuperPower
. However, not all superheroes can fly or use superpowers, so forcing them to implement those methods would be violating ISP.
Here’s a bad example of violating ISP in C#:
// Bad approach
public interface ISuperhero
{
void Fly();
void UseSuperPower();
void Fight();
}
public class Superman : ISuperhero
{
public void Fly()
{
// Fly like Superman
}
public void UseSuperPower()
{
// He is called Superman for a reason!
}
public void Fight()
{
// Fight like Superman
}
}
public class Batman : ISuperhero
{
public void Fly()
{
// Batman doesn't fly!
}
public void UseSuperPower()
{
// Batman has no super power, even though he is super rich!
}
public void Fight()
{
// Fight like Batman
}
}
As you can see, the Superhero
interface includes methods that not all superheroes can use. In the example above, Batman is forced to implement the Fly
and UseSuperPower
methods, even though he can’t fly and doesn’t have superpowers. This violates ISP, as Batman is being forced to depend on methods he doesn’t use.
Here’s a better approach using ISP:
// Better approach - using ISP
public interface IFly
{
void Fly();
}
public interface ISuperPower
{
void UseSuperPower();
}
public interface IFight
{
void Fight();
}
public class Superman : IFly, ISuperPower, IFight
{
public void Fly()
{
// Fly like Superman
}
public void UseSuperPower()
{
// Use super power
}
public void Fight()
{
// Fight like Superman
}
}
public class Batman : IFight
{
public void Fight()
{
// Fight like Batman
}
}
In the example above, we’ve created three separate interfaces for each type of superhero ability: IFly
, ISuperPower
and IFight
. This way, we can create superheroes that only implement the interfaces that are relevant to their abilities, and we’re not forcing any superhero to depend on methods they don’t use.
This approach follows ISP, and it’s much more flexible and scalable than the previous example.
Dependency Inversion Principle (DIP):
The DIP states that high-level modules should not depend on low-level modules, but instead depend on abstractions. To illustrate this, let’s look at an example of a class that violates the DIP.
// Bad approach
public class FileLogger
{
public void Log(string message)
{
// Log message to file
}
}
public class Calculator
{
private readonly FileLogger _logger;
public Calculator()
{
_logger = new FileLogger();
}
public int Add(int a, int b)
{
int result = a + b;
_logger.Log($"Addition of {a} and {b} is {result}");
return result;
}
}
In this example, the Calculator
class depends on the FileLogger
class, which means that any changes to the FileLogger
class could potentially break the Calculator
class. Additionally, if we want to log to a different location or use a different logging mechanism, we would need to modify the Calculator
class.
To follow the Dependency Inversion Principle (DIP), we need to invert the dependency between the Calculator
and FileLogger
classes. We can do this by introducing an abstraction that both classes depend on:
// Good approach - using DIP
public interface ILogger
{
void Log(string message);
}
public class FileLogger : ILogger
{
public void Log(string message)
{
// Log message to file
}
}
public class ConsoleLogger : ILogger
{
public void Log(string message)
{
// Log message to console
}
}
public class Calculator
{
private readonly ILogger _logger;
public Calculator(ILogger logger)
{
_logger = logger;
}
public int Add(int a, int b)
{
int result = a + b;
_logger.Log($"Addition of {a} and {b} is {result}");
return result;
}
}
In this example, we have introduced an ILogger
interface that represents any type of logging mechanism. The Calculator
class now depends on this interface instead of the FileLogger
class. This means that we can pass in any type of logger that implements the ILogger
interface to the Calculator
class.
We can now create new loggers that implement the ILogger
interface and use them with the Calculator
class without modifying it:
ILogger logger = new ConsoleLogger(); // or new FileLogger();
Calculator calculator = new Calculator(logger);
calculator.Add(3, 4);
By inverting the dependency between the Calculator
and FileLogger
classes, we have made our code more flexible and easier to maintain. We can now easily add new types of loggers without modifying the Calculator
class.
Conclusion:
Code can be tricky,
But these principles will make it less sticky.
Single Responsibility keeps it clean,
One job for each class, that’s the dream!
Open-Closed Principle, you can be bold,
New features can be added, without changing the code of old.
Liskov Substitution ensures things go right,
Derived classes can replace the base, it’s a sight!
Interface Segregation declutters your space,
Only use what you need, no code out of place.
Dependency Inversion is the base,
High-level modules, and low-level interface, embrace!
So remember these principles, one and all,
And your code will stand tall!
If you’ve reached this point “and enjoyed the rhyming conclusion 😬” please consider applauding 👏 and following my account for more content like this in the future.