SOLID
SOLID is a set of five rules for writing clean, flexible code.[1] When you follow them, your code becomes easier to understand, safer to change, and less likely to break when you add new features. Each letter stands for one principle:
| Letter | Full Name | Simple Meaning |
|---|---|---|
| S | Single Responsibility Principle | One class, one job |
| O | Open/Closed Principle | Add new features without changing old code |
| L | Liskov Substitution Principle | Child classes should behave like their parent |
| I | Interface Segregation Principle | Don't force a class to use things it doesn't need |
| D | Dependency Inversion Principle | Depend on rules (interfaces), not on specific things |
Robert C. Martin (known as "Uncle Bob") introduced these five principles in a 2000 paper, then expanded them in his book Agile Software Development (2003). The acronym SOLID was later arranged by Michael Feathers around 2004 to make them easier to remember. The problem they were solving: as software projects grew bigger, small changes kept breaking other parts. SOLID gives developers a guide to avoid that.
S: Single Responsibility Principle
A class should do one job only. Think of a restaurant — the chef cooks, the waiter serves. If the chef also takes orders and cleans tables, things get messy and confusing.
// ❌ Bad — User class is doing two jobs: storing data AND sending emails
public class User
{
public string Email { get; set; }
public void Register()
{
// saves user...
new EmailSender().Send("Welcome!", Email); // not its job
}
}
// ✅ Good — each class has one job
public class User { public string Email { get; set; } }
public class UserService
{
public void Register(User user)
{
// handles registration
new EmailSender().Send("Welcome!", user.Email);
}
}
O: Open/Closed Principle
Your code should be open to adding new things, but closed to changing existing working code. Like a power strip — you can plug in new devices without rebuilding the strip itself.
// ❌ Bad — to add a new shape, you must edit this method and risk breaking others
public double CalculateArea(Shape shape)
{
if (shape.Type == "Circle") return Math.PI * Math.Pow(shape.Radius, 2);
else if (shape.Type == "Rectangle") return shape.Length * shape.Width;
else throw new Exception("Unknown shape!");
}
// ✅ Good — adding Triangle means just adding a new class, nothing else changes
public abstract class Shape { public abstract double Area(); }
public class Circle : Shape { public override double Area() => Math.PI * Math.Pow(Radius, 2); }
public class Rectangle : Shape { public override double Area() => Length * Width; }
public class Triangle : Shape { public override double Area() => 0.5 * Base * Height; }
L: Liskov Substitution Principle
If class B is a child of class A, you should be able to use B anywhere A is used and the program should still work correctly. If swapping them causes unexpected results, something is wrong.
A classic example of breaking this rule is making Square a child of Rectangle. A square forces both sides to be equal, so setting Width = 5 and Height = 10 on a Square gives you area of 25, not 50. It pretends to be a Rectangle but behaves differently.
// ✅ Good — Square and Rectangle are separate, both honest about what they are
public abstract class Shape { public abstract double Area { get; } }
public class Rectangle : Shape { public double Width, Height; public override double Area => Width * Height; }
public class Square : Shape { public double Side; public override double Area => Side * Side; }
I: Interface Segregation Principle
Don't force a class to use methods it doesn't need. Keep interfaces small and focused. A 2D shape like a circle has no volume — it shouldn't be forced to implement a Volume() method just because it shares an interface with 3D shapes.
// ❌ Bad — Circle is forced to implement Volume() which makes no sense for it
public interface IShape { double Area(); double Volume(); }
// ✅ Good — separate interfaces for 2D and 3D shapes
public interface IShape2D { double Area(); }
public interface IShape3D { double Area(); double Volume(); }
public class Circle : IShape2D { public double Area() => Math.PI * Radius * Radius; }
public class Sphere : IShape3D { public double Area() => 4 * Math.PI * Radius * Radius; public double Volume() => (4.0/3) * Math.PI * Math.Pow(Radius, 3); }
D: Dependency Inversion Principle
High-level code (your main logic) should not depend directly on low-level code (specific tools). Both should depend on an interface — a shared rule that different things can follow. Think of a wall socket: your phone charger depends on the socket standard, not on which company provides the electricity.
// ❌ Bad — Car is locked to one specific Engine type
public class Car
{
private Engine engine = new Engine(); // tightly locked
public void Start() => engine.Start();
}
// ✅ Good — Car accepts any engine that follows the IEngine rule
public interface IEngine { void Start(); }
public class PetrolEngine : IEngine { public void Start() => Console.WriteLine("Petrol started."); }
public class ElectricEngine : IEngine { public void Start() => Console.WriteLine("Electric started."); }
public class Car
{
private IEngine engine;
public Car(IEngine engine) { this.engine = engine; }
public void Start() => engine.Start();
}
// Now you can swap engines without touching Car at all
var car = new Car(new ElectricEngine());
How the Five Principles Work Together
The five principles support each other rather than standing alone:
- S and I both keep things small and focused — SRP at the class level, ISP at the interface level.
- O and D work as a pair: to add features without changing existing code (OCP), you need to depend on interfaces not specific classes (DIP). DIP gives you the tool that makes OCP possible.
- L makes D and O safe: swapping one class for another (which DIP and OCP depend on) only works if the replacement behaves correctly — which is exactly what LSP guarantees.