SOLID comes up in every interview and in every code review. Many people can recite the five letters. Far fewer can spot the moment a principle really applies, and even fewer the moment applying it would be a mistake.
This article goes through the five principles with short examples. It then widens to the other good practices that help every day, and it ends with what SOLID does not say.
Where it comes from
The acronym was coined by Michael Feathers in the early 2000s. It groups principles collected by Robert C. Martin. Some are older: the open/closed principle comes from Bertrand Meyer in 1988 and Liskov substitution from Barbara Liskov in 1987. They all answer the same question: how do you write code you can still change in six months without breaking everything?
An important word before starting. These are principles, not rules. They give a direction, they do not set a threshold. 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 subtler than the one people repeat. A class should answer to a single actor: a single group of people able to ask for 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 tech team
}
public function renderPdf(Invoice $invoice): string
{
// Layout decided by marketing
}
}
Three reasons to change. Three different actors. A simple change of visual identity forces you to touch a class that also holds the VAT calculation. The risk of breakage is direct.
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
It is the most misapplied of the five. Pushed to the extreme, it produces classes with a single three-line method and an architecture where following one process means opening fifteen files.
The right indicator is not the number of methods. Ask yourself this instead: could two different people ask to change this class? If so, split it. If not, leave it alone.
O - Open/Closed Principle
Open for extension, closed for modification.
In plain words: adding a behaviour should not force you to touch code that is already 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("Unknown method");
}
}
Every new shipping method forces you to reopen this method. It grows, and each change puts the methods 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 every available implementation into the calculator.
@Service
public class ShippingCalculator {
private final Map<String, ShippingMethod> methods;
public ShippingCalculator(List<ShippingMethod> methods) {
this.methods = methods.stream()
.collect(Collectors.toMap(ShippingMethod::code, Function.identity()));
}
public BigDecimal calculate(String code) {
ShippingMethod method = methods.get(code);
if (method == null) {
throw new IllegalArgumentException("Unknown method: " + code);
}
return method.cost();
}
}
Adding a method now 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 pays off when you notice you keep coming back to change the same condition, 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 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;
}
@Override
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}
In maths, a square is a rectangle. In object-oriented code, it is not.
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
// We expect 20. We get 16.
System.out.println(r.area());
The square keeps its sides equal, as it should. So the last call to setHeight(4) also changes the width. The calling code respects the Rectangle contract and gets a wrong result. The subclass broke the parent's promise.
How to spot it
Three signals that never lie.
An overridden method that throws UnsupportedOperationException.
A subclass that rejects values the parent accepted.
Calling code that checks the real type with instanceof before acting. That one is the most telling. If you need to know which subclass you are handling, polymorphism is not working.
The fix
Often, inheritance was simply the wrong tool. Two separate classes behind a shared 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 question 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 has no internal timesheet to fill in. They still have to implement the method with an empty body or an exception, which also breaks 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 nothing shocking. It is often the sign of a well-targeted abstraction.
D - Dependency Inversion Principle
Business code must not depend on technical code. Both depend on abstractions.
It is the most structuring 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 many articles miss. The direction of the dependency matters. The OrderRepository interface belongs to the business layer, not to the technical layer. The business says what it needs and the infrastructure complies. Without that, you have only added an interface without inverting anything at all.
In Laravel, the binding happens in a service provider.
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
}
In Spring, it is resolved automatically as soon as a single implementation exists.
The other principles that matter
SOLID does not cover everything. These four help at least as much every day.
DRY (Don't Repeat Yourself)
Do not repeat yourself. A given piece of knowledge should live in one place only.
Beware of the misreading. DRY is about knowledge, not about code that looks alike. Two functions that are almost identical today but answer two different needs will end up diverging. Merging them creates artificial coupling, and you will pay for it later with a function full of conditions.
The practical rule: wait for the third occurrence. Twice can be a coincidence. Three times is a pattern.
KISS (Keep It Simple, Stupid)
Keep it simple. Between two solutions that work, the most 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);
// Plain and immediate
$total = 0;
foreach ($items as $item) {
$total += $item->qty * $item->price;
}
The second one is not worse. It is just longer, and faster to understand for whoever reads your code next.
YAGNI (You Ain't Gonna Need It)
You will not 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 badly designed, since you thought it up without knowing the second use case.
The Law of Demeter
Only talk to your direct neighbours.
// Three levels of knowledge about objects that are none of your business
$city = $order->getCustomer()->getAddress()->getCity();
// The contract is clear
$city = $order->getShippingCity();
Each extra dot is an extra dependency. If Address changes, the first line breaks with nothing to warn you.
Signs of decaying code
A few concrete symptoms to watch for in review.
The class that keeps growing. Beyond three hundred lines, ask yourself the question. It is not a hard limit. It is a warning threshold.
The method with more than three parameters. Often the sign that an object is missing.
// Hard 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 by 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);
}
The 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.
// Useless, the code already says it
// Increment the counter
$count++;
// Useful, the code cannot say it
// The supplier sometimes sends the same line twice,
// so we deduplicate before invoicing
$lines = collect($lines)->unique('reference');
The 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 criticism is fair.
They date 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 irrelevant.
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 one. The complexity has not disappeared, it has just moved.
The right stance fits in one sentence. Write the most direct code that solves today's problem. When a change becomes painful, ask yourself which of these principles would have avoided the pain. Refactor at that moment. A refactoring driven by a real pain is always better than a preventive abstraction.
Further reading
- Clean Code by Robert C. Martin. Read it with some distance, some advice has aged. The chapters on names and functions remain excellent
- Refactoring by Martin Fowler. The catalogue of transformations, with the symptom that triggers each one
- Fowler's online catalogue
- The PSRs for PHP conventions
- Effective Java by Joshua Bloch for the Java side
One exercise to finish. Take a class from your project that is over three hundred lines long, list its methods and write next to each one who could ask to change it. If you find at least two different actors, you have 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!