SOLID is an acronym for five fundamental design principles that help developers create software that is easy to maintain, extend, and test. When you understand these, you stop fighting your own code and start building robust architectures.
Here is how to understand them once and for all, with C# examples :)
1. S: Single Responsibility Principle (SRP)
"A class should have only one reason to change."
A class should handle one specific job. If a class handles business logic and file I/O and email notifications, it has too many reasons to change.
❌ The Bad Way
Here, the UserService is doing too much: registering a user AND sending an email. If the email logic changes, we risk breaking the user registration.
csharppublic class UserService
{
public void Register(string email, string password)
{
// 1. Validation Logic
if (!email.Contains("@"))
throw new ValidationException("Invalid email");
// 2. Database Logic
var user = new User(email, password);
Database.Save(user);
// 3. Email Logic (Violation: This belongs elsewhere!)
var smtpClient = new SmtpClient("smtp.google.com");
smtpClient.Send("admin@app.com", email, "Welcome!", "Hi there!");
}
}
✅ The SOLID Way
We extract the email responsibility to its own class. UserService now only focuses on the user.
csharppublic class EmailService
{
public void SendWelcomeEmail(string email)
{
// Email logic isolated here
Console.WriteLine($"Sending email to {email}");
}
}
public class UserService
{
private readonly EmailService _emailService;
public UserService(EmailService emailService)
{
_emailService = emailService;
}
public void Register(string email, string password)
{
// Validation & Database logic...
Database.Save(new User(email, password));
// Delegate email responsibility
_emailService.SendWelcomeEmail(email);
}
}
2. O: Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
You should be able to add new features (like a new shape or payment method) without rewriting existing, tested code.
❌ The Bad Way
If we want to add a Triangle, we have to modify the AreaCalculator class and add a new case to the switch statement. This risks introducing bugs in existing logic.
csharppublic class AreaCalculator
{
public double CalculateArea(object shape)
{
if (shape is Rectangle r)
{
return r.Width * r.Height;
}
else if (shape is Circle c)
{
return Math.PI * c.Radius * c.Radius;
}
// Every new shape requires modifying this file!
return 0;
}
}
✅ The SOLID Way
We use polymorphism. AreaCalculator doesn't care what shape it is, as long as it implements Shape. We can add a Triangle class later without touching AreaCalculator.
csharppublic abstract class Shape
{
public abstract double CalculateArea();
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double CalculateArea() => Width * Height;
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double CalculateArea() => Math.PI * Radius * Radius;
}
public class AreaCalculator
{
// Closed for modification: This method never needs to change again
public double CalculateArea(Shape shape)
{
return shape.CalculateArea();
}
}
3. L: Liskov Substitution Principle (LSP)
"Subtypes must be substitutable for their base types."
If code works with a Bird class, it should work with any class that inherits from Bird (like Penguin) without blowing up.
❌ The Bad Way
A Penguin is a Bird, but it can't fly. Throwing an exception violates the expectation of the base class.
csharppublic class Bird
{
public virtual void Fly()
{
Console.WriteLine("I am flying!");
}
}
public class Penguin : Bird
{
public override void Fly()
{
// Violation: Breaking the behavior of the parent class
throw new NotImplementedException("Penguins can't fly!");
}
}
✅ The SOLID Way
Separate the hierarchies. Not all birds fly.
csharppublic abstract class Bird { /* common bird stuff like Eat() */ }
public interface IFlyable
{
void Fly();
}
public class Sparrow : Bird, IFlyable
{
public void Fly() => Console.WriteLine("Flying high!");
}
public class Penguin : Bird
{
// No Fly method here, so no false expectations.
public void Swim() => Console.WriteLine("Swimming!");
}
4. I: Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they do not use."
Don't create massive "God Interfaces." It's better to have three specific interfaces than one general-purpose one.
❌ The Bad Way
We have an IMachine interface. A simple "Old Fashioned Printer" can't scan, but it's forced to implement the Scan method just to satisfy the interface.
csharppublic interface IMachine
{
void Print(Document d);
void Scan(Document d);
void Fax(Document d);
}
public class OldPrinter : IMachine
{
public void Print(Document d) { /* print */ }
// Violation: Forced to implement methods it doesn't need
public void Scan(Document d) => throw new NotImplementedException();
public void Fax(Document d) => throw new NotImplementedException();
}
✅ The SOLID Way
Break the interface down. Now OldPrinter only implements what it can actually do.
csharppublic interface IPrinter { void Print(Document d); }
public interface IScanner { void Scan(Document d); }
public interface IFaxer { void Fax(Document d); }
public class OldPrinter : IPrinter
{
public void Print(Document d) { /* print */ }
}
public class SuperSmartPhotocopier : IPrinter, IScanner, IFaxer
{
public void Print(Document d) { /* ... */ }
public void Scan(Document d) { /* ... */ }
public void Fax(Document d) { /* ... */ }
}
5. D: Dependency Inversion Principle (DIP)
"Depend on abstractions, not on concretions."
High-level modules (business logic) should not depend on low-level modules (database connections, file writers). Both should depend on interfaces.
❌ The Bad Way
The NotificationService is tightly coupled to EmailSender. If we want to switch to SMS, we have to rewrite the NotificationService.
csharppublic class EmailSender
{
public void Send(string message) => Console.WriteLine($"Email: {message}");
}
public class NotificationService
{
private EmailSender _sender;
public NotificationService()
{
// Violation: Directly creating a dependency (tight coupling)
_sender = new EmailSender();
}
public void Notify(string message)
{
_sender.Send(message);
}
}
✅ The SOLID Way
We introduce an interface IMessageSender. The NotificationService doesn't care if it's Email or SMS; it just knows it can SendMessage(). This is typically wired up using Dependency Injection.
csharp// 1. The Abstraction
public interface IMessageSender
{
void SendMessage(string message);
}
// 2. The Low-Level Details
public class EmailSender : IMessageSender
{
public void SendMessage(string message) => Console.WriteLine($"Email: {message}");
}
public class SmsSender : IMessageSender
{
public void SendMessage(string message) => Console.WriteLine($"SMS: {message}");
}
// 3. The High-Level Module
public class NotificationService
{
private readonly IMessageSender _sender;
// We inject the interface via the constructor
public NotificationService(IMessageSender sender)
{
_sender = sender;
}
public void Notify(string message)
{
_sender.SendMessage(message);
}
}
Summary Table
| Principle | Main Idea | C# Keyword to remember |
|---|
| SRP | One class, one job. | N/A (Organizational) |
| OCP | Add new code, don't change old code. | abstract, override |
| LSP | Parent class features must work in Child class. | Inheritance (:) |
| ISP | Small interfaces > Big interfaces. | interface |
| DIP | Use interfaces to decouple classes. | interface, Constructor Injection |