Table of contents
Introduction
Programming is a practice that lies halfway between science and art. It requires both specific technical knowledge and mental flexibility, as well as creativity for problem-solving. As I often say, "Code has no dogmas"; every goal can be achieved in many different ways, all equally valid. With that said, there are a series of principles that allow code to be more functional and maintainable. While they may not be necessary at the very beginning of your learning path, they become more important over time when you decide to increase your reliability as a developer and the quality of your code.
So, today we are talking about the SOLID principles. These are a series of concepts related to OOP that make code maintainable over time.
To explain the aim of these principles, Robert C. Martin in "Agile Software Development" starts by explaining the "symptoms" that code exhibits when they are broken:
Rigidity: The application is hard to modify, and each change requires many other changes in the system.
Fragility: Changes to the code cause breaks in parts of the program that are not directly related to the modified code.
Immobility: It's difficult to divide the code into components that can be extracted and reused in other systems.
Viscosity: Doing things the right way is harder than using hacks or workarounds.
Unnecessary Complexity: The project contains structures that don't bring any actual benefit.
Unnecessary Repetition: The code contains superfluous repetitions that could be consolidated into a single abstraction.
Opacity: The code is difficult to read and understand.
These are signs (directly quoted from "Agile Software Development") that indicate our code has some problems that, if not resolved, will eventually make development a nightmare for us and our co-workers. The point is to have the ability to work effectively on the code and make it stable enough to allow the application to grow without risking breaking it.
It's important to start from these symptoms because everyone (from juniors to seniors) has encountered these types of issues with our code. And this is where the SOLID principles come into play! So let's check them out together
S - Single-Responsibility Principle
The Single-Responsibility Principle (SRP) is perhaps the most well-known among the SOLID principles, but it is also the most frequently misunderstood or at least misleading.
Because of its name, it is often remembered or interpreted as "one component (or one class) should only do one thing." While the result of applying this principle is more or less the same, it's essential to understand its true meaning.
Within the context of this principle, "Responsibility" refers to a "reason for the component to change." So, according to this principle:
A class should have only one reason to change
Violating this principle can easily lead to fragile code because if multiple reasons exist to modify a component, it becomes more likely to introduce bugs, and when a bug occurs, it can break the entire component, even the parts not directly related to the code where the error originated.
To see an example of a class that violates the SRP, take a look at the first sample code.
class User{
private $username;
/* ... */
public function setUsername(String $username): User
{
$this->username = $username;
return $this;
}
public function getUsername(): String
{
return $this->username;
}
/* ... */
public function saveToDb()
{
/*... saving the user to whatever DB */
}
}
We have a PHP class, User
, that collects and manages all the user's properties, and it also has a method to save the model in the database.
This class can change both when we add additional methods and properties that define the User
, and when we modify the way we interact with the database. If an error occurs in either part, it could break the entire User
class.
To address this problem, we can split the code into two parts.
class User{
private $username;
/* ... */
public function setUsername(String $username): User
{
$this->username = $username;
return $this;
}
public function getUsername(): String
{
return $this->username;
}
}
class UserRepository{
// Takes care of all interactions with the DB
public function create($userData){
$user = new User();
$user->setUsername($userData->username);
/* ... and so on ...*/
return $this->saveToDb($user);
}
public function saveToDb(){
}
}
The first part will be the User
class, which contains all the properties and methods describing the user. The second part will be the UserRepository
, class, which handles the interactions of the User
class with the database.
By organizing the code this way, each of the two classes will have its own single reason to change (a modification to the model or a modification to the database interactions), and we won't risk encountering fragility on this front.
O - Open-Closed Principle
The Open-Closed Principle indicates how to organize our code to allow our application to grow while maintaining stable and organized code. Let's see what it says:
Software entities ( classes, modules, functions ) should be open for extension, but closed for modification.
What does this mean? It means that we should design our code (classes, components, functions, etc.) so that when we need to add a new feature to our application, we can do it by extending the existing modules rather than modifying the existing ones that are already functional.
Let's try to understand it better. Suppose we are developing an application that needs to display information about multimedia content. Knowing that we currently have only Movie
and TvShows
, we write this code:
class Movie
{
public function renderMovieInformations(){
//...
}
}
class TvShow
{
public function renderShowInformations(){
//...
}
}
function renderInformation($medias){
foreach ($medias as $media) {
if(get_class($media) == 'TvShow' ){
$media->renderShowInformations();
}elseif(get_class($media) == 'Movie'){
$media->renderMovieInformations();
}else{
// ...
}
}
}
However, when we are asked to implement "Songs" as well, we would be forced to change the above code. We realize that adding any new content type will cause this if
(or switch
) to grow, making it less maintainable over time:
class Movie
{
public function renderMovieInformations(){
//...
}
}
class TvShow
{
public function renderShowInformations(){
//...
}
}
class Song
{
public function renderSongInformations(){
//...
}
}
function renderInformation($medias){
foreach ($medias as $media) {
if(get_class($media) == 'TvShow' ){
$media->renderShowInformations();
}elseif(get_class($media) == 'Movie'){
$media->renderMovieInformations();
}elseif(get_class($media) == 'Somg'){
$media->renderSongInformations();
}else{
// ...
}
}
}
Furthermore, in a real-world application, the rendering of information would appear with slight variations in various parts of the application. This would force us to modify and check all occurrences whenever we add a new content type, making the code fragile.
So, how can we solve this issue? The solution lies in working with abstractions. In the case of PHP, which we are working with, we need an Interface
. This is because an Interface
defines a set of methods that must be implemented by the classes that use it, providing a blueprint to follow. We can then achieve the following:
interface Media
{
public function renderMediaInformations();
}
class Movie implements Media
{
public function renderMediaInformations(){
//...
}
}
class TvShow implements Media
{
public function renderMediaInformations(){
//...
}
}
class Song implements Media
{
public function renderMediaInformations(){
//...
}
}
function renderInformations($medias){
foreach($medias as $media){
$media->renderMediaInformations();
}
}
Using the Media
interface, we define the methods that each implementing class must develop. This way, even when we add other types of multimedia content, the renderInformations()
function will no longer be modified; we will simply create a new class that implements the Media
interface.
A small note regarding this principle: A significant mistake one could make is trying to abstract everything to close everything from any change. This is not feasible and would essentially lead to development stagnation. It's better to imagine plausible changes and prepare for those, while also being open to unexpected ones when they occur. We are developers, not fortune-tellers, and pragmatism should always guide our work.
L - Liskov Substitution Principle
The Liskov Substitution Principle (LSP) can be expressed in multiple ways, with the simplest being:
Subclasses must be substitutable for their base class.
This principle emphasizes the use of inheritance and polymorphism. Substitutability of a child class for a parent class occurs when replacing a parent class with that of a derived class at a certain point in the code while the code's behavior remains valid. If not, it's a violation of the LSP. If a developer modifies the previously working code of the parent class to prevent the behavior from becoming invalid, they will violate the Open-Closed Principle (OCP).
Essentially, a child class should extend the behaviors of the parent class rather than modify them. Otherwise, we risk violating both the LSP and the OCP. In other words, a violation of the Liskov Substitution Principle often conceals a violation of the Open-Closed Principle.
Let's proceed with an example, this time relying on a classic scenario that's very useful to understand this principle better:
class Rectangle{
public $width;
public $height;
public function getWidth()
{
return $this->width;
}
public function setWidth($width)
{
$this->width = $width;
return $this;
}
public function getHeight()
{
return $this->height;
}
public function setHeight($height)
{
$this->height = $height;
return $this;
}
public function getArea(){
return $this->width * $this->height;
}
}
class Square extends Rectangle{
public function setWidth($width)
{
$this->width = $width;
$this->height = $width;
return $this;
}
public function setHeight($height)
{
$this->width = $height;
$this->height = $height;
return $this;
}
}
Consider the two classes, Rectangle
and Square
. It might seem that Square
is a subclass of Rectangle
with the peculiarity of having all sides equal. Intuitively, we modified the setWidth
and setHeight
methods so that if one dimension is set, the other adjusts accordingly. Everything might seem to fit.
But what happens when, during testing, we pass an instance of Square
instead of Rectangle
to the following testArea()
function?
function testArea(Rectangle $rect){
$rect->setHeight(4);
$rect->setWidth(5);
return $rect->getArea() == 20;
}
We receive two different results, highlighting that Square
doesn't pass the test and, despite what we thought, isn't substitutable for Rectangle
. The Liskov Substitution Principle is violated.
From this, we can understand a few things. First, Square
doesn't derive from Rectangle
; they share some aspects that could be included in a single abstraction, but one isn't derived from the other.
Second, violations of the Liskov Substitution Principle are detectable only when the fragility of our code becomes apparent. This fragility is tied to how we use the classes we write/have at our disposal. It might surface on the first day or never at all.
I - Interface Segregation Principle
The Interface Segregation Principle tells us that:
Clients should not be forced to depend on interfaces they do not use.
Among all the SOLID principles, this is perhaps the most straightforward to both comprehend and apply. Essentially, what is conveyed is not to create "fat" interfaces, meaning interfaces with methods unrelated to all classes that will implement that same interface. Instead, it's preferable to have multiple interfaces, each with methods that are certainly needed for the classes that will implement them. This preference is because we don't want to change a class implementing an interface based on unused methods, nor make subsequent changes to accommodate requirements that aren't truly necessary.
Let's imagine we're developing a server manager, and we'll use the interface
Server
shown in this example:
interface Server{
public function start():void;
public function stop():void;
public function delete():void;
public function startMySql():void;
public function stopMySql():void;
public function runMySqlQuery():void;
}
Not all server classes we create will require the methods related to MySql, as not all servers use it. However, if we write a class called SimpleServer, which implements the Server
interface, we'll get an error if we don't define all the methods described by the interface, and if I were to add additional methods or change the descriptions provided in the interface (perhaps by adding parameters), I would be forced to make the same changes to the class, even if it doesn't use them.
We apply the Interface Segregation Principle, dividing the Server interfaces into two interfaces, one containing methods relevant to all servers and another containing methods needed only for those using MySql.
interface Server{
public function start():void;
public function stop():void;
public function delete():void;
}
interface UseMySqlServer{
public function startMySql():void;
public function stopMySql():void;
public function runMySqlQuery():void;
}
Now we can create classes that implement one or both interfaces and keep the code more organized.
D - Dependency Inversion Principle
The Dependency Inversion Principle tells us that:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
What do we mean by high-level and low-level modules? In short, high-level modules are classes closer to the presentation layer, dealing with logic closer to the end user. On the other hand, low-level modules are more distant from the user, generally classes and functions used by high-level modules to carry out their tasks.
So, what does this principle require concretely?
It requires not directly importing modules containing logic details into high-level modules, but interposing an abstraction between them. This provides greater stability to high-level modules and ensures proper adherence to the OCP in low-level modules (this strategy reverses the dependency direction of the low-level module, making it depend on the abstraction, hence the name of the principle).
As for the other principles, let's make everything clearer with an example:
<?php
class StripeProvider{
}
class CheckoutController{
public $paymentProvider;
public function __construct(StripeProvider $stripeProvider)
{
$this->paymentProvider = $stripeProvider;
}
}
In this snippet the CheckoutController
, our high-level module, directly imports a hypothetical StripeProvider
in the __construct()
to handle payments in the Stripe checkout. This way, the CheckoutController
, which sends information to the view, becomes entirely dependent and subject to any changes made to the StripeProvider
. Moreover, what if we wanted to add, for example, PayPal as a payment method? We would need to add a new parameter and modify the existing code, breaking the OCP.
How to solve this issue?
It's enough to introduce an interface that acts as an intermediary between the CheckoutController
and the StripeProvider
, like in this example:
<?php
interface PaymentProviderInterface
{
}
class StripeProvider implements PaymentProviderInterface
{
}
class CheckoutController
{
public $paymentProvider;
public function __construct(PaymentProviderInterface $paymentProvider)
{
$this->paymentProvider = $paymentProvider;
}
}
This way, the Controller will only need to call the methods provided by the Interface
, regardless of how they are implemented. Additionally, we are free to have multiple provider classes, one for each payment method, implementing their respective logics without altering the previously functional code.
This approach respects the DIP and the OCP, making the code stable and maintainable.
While it may seem counterintuitive at first glance, adhering to this principle allows for the stable construction of complex applications and is fundamental to the structure of many frameworks.
Conclusion
These were the SOLID principles, a series of concepts that every developer working with OOP should learn at a certain point in their career. They need some time to be learnt and mastered, but keeping them in mind can really help out to build better and more stable code.
I really hope this article was useful for those who hadn't ever studied them and also for those who already knew them, because a review it's always useful.
You can find all the principles, one by one and the code examples on github ( I plan to keep updating it, from time to time wiht more examples and content, so if you're interested leave a star so you know when something happens )
If you have any questions, feedback or just wanna reach out, feel free to write me in the comments, on twitter @gosty93 or on Linkedin
Happy Coding 1_ 0
Top comments (0)