Every software project starts out simple. A few classes and a handful of methods, where things are easy to understand and change. But if the software is useful, it will inevitably grow. Features are added, requirements change, and complexity creeps in - whether that code is written by humans or with the help of AI.
The problem is never growth itself. The problem is unmanaged complexity. When complexity isn’t actively managed through good software design principles, it slowly erodes the codebase until even small changes become risky and expensive. Eventually, the only option is to start over.
The SOLID principles are five design principles that help you manage that complexity so your software can keep growing without collapsing under its own weight.
1. Single Responsibility Principle (SRP)
A class should have one reason to change.
Let’s take a look at a BlogPostService that handles fetching posts, formatting them as HTML, and lastly emailing them to subscribers:
class BlogPostService
{
public BlogPost GetById(int id) { }
public string FormatAsHtml(BlogPost post) { }
public void EmailToSubscribers(BlogPost post) { }
}
This already feels cluttered. What happens when you need to add functionality for creating, editing or deleting blog posts? Or supporting a new format? Or switching email providers? Currently, everything lives in the same class. Over time it becomes harder to understand, harder to test, and therefore harder to make changes safely.
That feeling of something being off is called a code smell. It is a signal that the current design has a flaw and that a better design is available.
In this case the fix is very straightforward, which is to separate each responsibility into its own focused class.
class BlogPostRepository
{
public BlogPost GetById(int id) { }
}
class BlogPostFormatter
{
public string FormatAsHtml(BlogPost post) { }
}
class BlogPostMailer
{
public void EmailToSubscribers(BlogPost post) { }
}
Now each class has a single purpose. When you make changes to how posts are being emailed it won’t introduce risk of breaking how they’re fetched or formatted, and vice versa. Each one of them can be tested and extended independently in a safe manner.
2. Open-Closed Principle (OCP)
Software should be open for extension, but closed for modification.
Imagine a NotificationSender class with a method for each notification system:
class NotificationSender
{
public void SendEmail(string message) { }
public void SendSms(string message) { }
}
When a new notification system is needed like Slack or other - you keep modifying the same class. That means every change you make risks breaking existing functionality and the class keeps becoming more difficult to manage.
In this case, the fix is to create an abstraction, so you can define an abstract class and inherit it by adding new classes rather than changing existing ones - as shown below:
abstract class NotificationSender
{
public abstract void Send(string message);
}
class EmailNotificationSender : NotificationSender
{
public override void Send(string message) { }
}
class SmsNotificationSender : NotificationSender
{
public override void Send(string message) { }
}
class SlackNotificationSender : NotificationSender
{
public override void Send(string message) { }
}
A method that works with the base type now works with all of them automatically:
void Notify(NotificationSender sender, string message) => sender.Send(message);
Adding a new notification system means adding a new class - no changes to existing code. Then you can rely on polymorphism to do the rest of the work.
Hope at this point you see where this principle got its name - open for extension, closed for modification.
3. Liskov Substitution Principle (LSP)
A subtype must be usable in place of its base type without breaking expected behavior.
Consider an abstract Employee class with two methods: Work and GetHealthBenefits:
abstract class Employee
{
public abstract void Work();
public abstract void GetHealthBenefits();
}
class FullTimeEmployee : Employee
{
public override void Work() { }
public override void GetHealthBenefits() { }
}
class Contractor : Employee
{
public override void Work() { }
public override void GetHealthBenefits() => throw new NotImplementedException(); // Contractors are not eligible to receive health benefits
}
Now there’s a method that processes any Employee:
void Process(Employee employee)
{
employee.Work();
employee.GetHealthBenefits(); // Runtime failure if employee is a Contractor
}
Passing a FullTimeEmployee works, but passing a Contractor crashes at runtime because contractors are not eligible to receive health benefits. Therefore LSP is broken, because a subtype can’t safely substitute for its base type.
So, in other words full-time employees receive employer-sponsored health benefits, while contractors are typically responsible for purchasing their own. Essentially, this functionality shouldn’t be on the base class as something all employees must support, because they can’t!
The fix is to remove GetHealthBenefits from the base class and move it to an interface - making it an optional capability that only the classes which actually support it can go ahead and implement.
abstract class Employee
{
public abstract void Work();
}
interface IHealthBenefitsEligible
{
void GetHealthBenefits();
}
class FullTimeEmployee : Employee, IHealthBenefitsEligible
{
public override void Work() { }
public void GetHealthBenefits() { }
}
class Contractor : Employee
{
public override void Work() { }
}
Now Process can still accept an Employee type and only call GetHealthBenefits on types that implement IHealthBenefitsEligible. No more runtime crashes!
4. Interface Segregation Principle (ISP)
No code should be forced to depend on methods it does not use.
Imagine an IWorker interface that has some general methods describing everything a worker might do:
interface IWorker
{
void Work();
void Eat();
void TakeBreak();
}
But now imagine, you have a robot worker… who can only work and doesn’t require eating or taking breaks. You get something like this:
class HumanWorker : IWorker
{
public void Work() { }
public void Eat() { }
public void TakeBreak() { }
}
class RobotWorker : IWorker
{
public void Work() { }
public void Eat() => throw new NotImplementedException(); // Robots don't eat
public void TakeBreak() => throw new NotImplementedException(); // Robots don't take breaks
}
RobotWorker is forced to implement methods it does not need. As the interface grows and more types are added, each needing only some of those methods, it quickly becomes a mess that’s hard to maintain.
The fix is actually not that complicated, just smaller and focused interfaces:
interface IWorkable { void Work(); }
interface IFeedable { void Eat(); }
interface IPausable { void TakeBreak(); }
class HumanWorker : IWorkable, IFeedable, IPausable
{
public void Work() { }
public void Eat() { }
public void TakeBreak() { }
}
class RobotWorker : IWorkable
{
public void Work() { }
}
Each class implements only what it actually needs. Adding a new capability means adding a new interface and that’s all, rest of the existing code stays untouched.
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Here’s a Car that creates its own engine internally:
class Car
{
private readonly GasolineEngine _engine;
public Car()
{
_engine = new GasolineEngine();
}
public void Start() => _engine.Run();
}
In other word the Car is tightly coupled to GasolineEngine. So if you decided to switch to an electric motor, adding a hybrid option or testing the car without a real engine it would be impossible. The reason is because the dependency is hardcoded.
The fix is to depend on abstractions. So instead of the high-level module (Car) reaching down to create a low-level module (GasolineEngine), the dependency is moved to an abstraction owned by the high-level module.
interface IEngine
{
void Run();
}
class GasolineEngine : IEngine
{
public void Run() { }
}
class ElectricMotor : IEngine
{
public void Run() { }
}
class Car(IEngine engine)
{
public void Start() => engine.Run();
}
Now Car depends on an interface and not a concrete class. You can plug in a GasolineEngine, an ElectricMotor, or any other engine that fits - without changing any of the code in Car at all. The Car doesn’t care what’s under the hood, as long as it can call Run.
The takeaway
These five principles - SRP, OCP, LSP, ISP, DIP - are collectively known as the SOLID principles. They aren’t rules that must be applied rigidly everywhere. Instead think of them as guidelines for better structuring your codebase to successfully manage complexity as your software grows.
There are other design approaches worth knowing - Domain-Driven Design, for example, which addresses how to manage complexity in large-scale systems. But SOLID gives you a great foundation for the majority of codebases you’ll deal with in your career.
The goal is software that keeps working as it grows - where adding a new feature doesn’t break three existing ones, and the codebase stays easy to extend no matter how complex it becomes.