SOLID comes up in almost every interview and code review. Many people know how to recite the five letters. Far fewer can spot when a principle really applies, and even fewer can recognize when applying it would be a mistake.
This article explains the five principles with short examples. It then expands to other good practices that are useful every day and ends with what SOLID does not say.
Where it comes from
The acronym was proposed by Michael Feathers in the early 2000s. It brings together principles formulated by Robert C. Martin, all answering the same question: how do you write code that you can modify six months from now without breaking everything?
One important point before we start. These are principles, not rules. They provide direction; they do not define thresholds. Code that follows SOLID to the letter but needs forty classes to send an email is not good code.
S - Single Responsibility Principle
A class should have only one reason to change.
Robert C. Martin's exact wording is more precise than the version we usually repeat. A class should answer to a single actor. A single group of people capable of requesting a change.
The problem
class InvoiceService
{
public function calculateTotal(Invoice $invoice): float
{
// Business rule decided by accounting
}
public function saveToDatabase(Invoice $invoice): void
{
// Persistence decided by the technical side
}
public function renderPdf(Invoice $invoice): string
{
// Formatting decided by marketing
}
}
Three reasons to change. Three different actors. A simple change to the visual style forces you to touch a class that also contains VAT calculation. The risk of breaking something is immediate.
The fix
class InvoiceCalculator
{
public function calculateTotal(Invoice $invoice): float { }
}
class InvoiceRepository
{
public function save(Invoice $invoice): void { }
}
class InvoicePdfRenderer
{
public function render(Invoice $invoice): string { }
}
The trap
This is the most misapplied of the five principles. Taken too far, it produces classes with a single three-line method and an architecture where following one operation requires opening fifteen files.
The right indicator is not the number of methods. Ask yourself instead: could two different people ask for this class to be changed? If yes, split it. Otherwise, leave it alone.
O - Open/Closed Principle
Open for extension, closed for modification.
Simple translation. Adding a behavior should not require changing code that has already been written.
The problem
public class ShippingCalculator {
public BigDecimal calculate(String method) {
if ("standard".equals(method)) {
return BigDecimal.valueOf(5.90);
}
if ("express".equals(method)) {
return BigDecimal.valueOf(12.50);
}
throw new IllegalArgumentException("Mode inconnu");
}
}
Every new shipping method requires reopening this method. It grows, and every change puts the modes that already worked at risk.
The fix
public interface ShippingMethod {
String code();
BigDecimal cost();
}
@Component
public class ExpressShipping implements ShippingMethod {
@Override
public String code() {
return "express";
}
@Override
public BigDecimal cost() {
return BigDecimal.valueOf(12.50);
}
}
Spring then injects all available implementations into the calculator. Adding a mode means creating a class. No existing file is touched.
When not to apply it
Three conditions that will never change do not justify an interface and three classes. The principle becomes worthwhile when you notice that you keep coming back to modify the same condition for the second or third time, not before.
L - Liskov Substitution Principle
A subclass must be able to replace its parent class without breaking anything.
The principle comes from Barbara Liskov in 1987. It is the most abstract of the five and the one most often misunderstood.
The classic example
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int area() {
return width * height;
}
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width;
}
}
In mathematics, a square is a rectangle. In object-oriented programming, it is not.
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
// Expected 20. Got 16.
System.out.println(r.area());
The calling code follows the Rectangle contract and gets an incorrect result. The subclass has broken the parent's promise.
How to spot it
Three telltale signs.
An overridden method that throws UnsupportedOperationException.
A subclass that rejects values that the parent accepted.
Calling code that checks the actual type with instanceof before acting. This is the clearest sign. If you need to know which subclass you are dealing with, polymorphism is not working.
The fix
Often, inheritance was simply the wrong tool. Two separate classes behind a common interface solve the problem.
public interface Shape {
int area();
}
public record Rectangle(int width, int height) implements Shape {
@Override
public int area() {
return width * height;
}
}
public record Square(int side) implements Shape {
@Override
public int area() {
return side * side;
}
}
The objects become immutable. The problem no longer arises.
I - Interface Segregation Principle
No one should depend on methods they do not use.
The problem
interface Employee
{
public function work(): void;
public function submitTimesheet(): void;
public function attendMeeting(): void;
}
An external contractor does not have an internal timesheet to submit. They would still have to implement the method with an empty body or an exception, which also violates Liskov along the way. The principles often overlap.
The fix
interface Worker
{
public function work(): void;
}
interface TimeTracked
{
public function submitTimesheet(): void;
}
class Contractor implements Worker
{
public function work(): void { }
}
class Employee implements Worker, TimeTracked
{
public function work(): void { }
public function submitTimesheet(): void { }
}
An interface with a single method is not a problem. It is often a sign of a well-targeted abstraction.
D - Dependency Inversion Principle
Business code should not depend on technical code. Both should depend on abstractions.
This is the most structurally important principle and the one that explains why Spring and Laravel work the way they do.
The problem
class OrderService
{
private MySqlOrderRepository $repository;
public function __construct()
{
$this->repository = new MySqlOrderRepository();
}
}
Your business rule depends on MySQL. You cannot test it without a database. You cannot change the storage without modifying it.
The fix
interface OrderRepository
{
public function findById(int $id): ? Order;
public function save(Order $order): void;
}
class OrderService
{
public function __construct(
private readonly OrderRepository $repository
) {
}
}
There is a detail that many articles miss. The direction of the dependency matters. The OrderRepository interface belongs to the business layer, not the technical layer. The business defines what it needs. The infrastructure conforms to it. Without this, you have simply added an interface without actually inverting anything.
In Laravel, the binding is done in a service provider.
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
In Spring, it is inferred automatically as soon as a single implementation exists.
The other principles that matter
SOLID does not cover everything. These four are at least as useful in everyday work.
DRY (Don't Repeat Yourself)
Do not repeat yourself. The same piece of knowledge should exist in only one place.
Be careful not to misunderstand it. DRY is about knowledge, not code that looks similar. Two functions that are almost identical today but serve two different needs will eventually diverge. Merging them creates artificial coupling, and you will pay for it later with a function full of conditions.
Practical rule: wait for the third occurrence. Twice may be a coincidence. Three times is a pattern.
KISS (Keep It Simple, Stupid)
Keep it simple. Between two solutions that work, the more readable one wins. Even if it is less clever.
// Clever, but you have to read it twice
$total = array_reduce($items, fn($c, $i) => $c + ($i->qty * $i->price), 0);
// Simple and immediate
$total = 0;
foreach ($items as $item) {
$total += $item->qty * $item->price;
}
The second is not worse. It is simply longer and quicker for the next person to understand.
YAGNI (You Ain't Gonna Need It)
You are not going to need it. Do not write code for an imaginary need.
It is the counterweight to the Open/Closed Principle. An interface with a single implementation, created just in case, is dead weight. It will also be poorly designed because you created it without knowing the second use case.
The Law of Demeter
Talk only to your immediate neighbors.
// Three levels of knowledge about objects that are none of your business
$city = $order->getCustomer()->getAddress()->getCity();
// The contract is clear
$city = $order->getShippingCity();
Every extra dot is another dependency. If Address changes, the first line breaks even though nothing made that obvious.
Signs of degrading code
A few concrete symptoms to watch for during code review.
The class that keeps growing. Beyond three hundred lines, ask yourself the question. It is not an absolute limit. It is an attention threshold.
A method with more than three parameters. Often a sign that an object is missing.
// Difficult to call without mixing up the order
public void createUser(String name, String email, String street, String city) { }
// The intent is clear
public void createUser(UserIdentity identity, Address address) { }
The boolean parameter. save($user, true) tells the reader nothing. Two well-named methods are better than a flag.
Nested conditions. Three levels can almost always be replaced with guard clauses.
// Before
public function process(Order $order): void
{
if ($order->isValid()) {
if ($order->hasStock()) {
if ($order->isPaid()) {
$this->ship($order);
}
}
}
}
// After
public function process(Order $order): void
{
if (!$order->isValid()) {
return;
}
if (!$order->hasStock()) {
return;
}
if (!$order->isPaid()) {
return;
}
$this->ship($order);
}
A comment that repeats the code. A comment should say why, not how. If your code needs a comment to be understood, start by renaming the variables.
// Unnecessary, the code already says it
// Increment the counter
$count++;
// Useful, the code cannot say it
// The provider sometimes returns the same line twice,
// deduplicate before invoicing
$lines = collect($lines)->unique('reference');
A name that says nothing. data, info, manager, helper, process. A good name lets you understand without opening the method.
What SOLID does not say
These principles are debated, and the criticisms are valid.
They come from a time when inheritance was everywhere and languages were more rigid. Some apply poorly to functional programming, where pure functions make several of these questions unnecessary.
They describe the desired result without saying when to act. Single Responsibility does not even define what a responsibility is. Yet that is the whole difficulty.
The real risk remains over-engineering. A small application buried under interfaces, factories, and abstraction layers is harder to maintain than a straightforward application. The complexity has not disappeared; it has simply moved.
The right mindset can be summed up in one sentence. Write the most straightforward code that solves today's problem. When a change becomes painful, ask yourself which of these principles could have prevented the pain. Refactor at that point. Refactoring driven by a real pain point is always better than preventive abstraction.
Further reading
- Clean Code by Robert C. Martin. Read it with some perspective; some advice has aged. The chapters on names and functions remain excellent
- Refactoring by Martin Fowler. A catalog of transformations and the symptom that triggers each one
- Fowler's online catalog
- PSRs for PHP standards
- Effective Java by Joshua Bloch for the Java side
One final exercise. Take a class from your project that exceeds two hundred lines, list its methods, and write next to each one who could request its modification. If you find more than two different actors, you have found your first split.
Comments (0)
Leave a comment
You can comment by entering your name and email. Your message will be published after moderation. With an account, it appears immediately and can still be edited.
No comments yet
Be the first to comment on this article!