SOLID Principles in OOP: Mastering Object-Oriented Design
Projects that adhere to SOLID principles can be more easily shared with other developers, extended, modified, tested and refactored. We will explain these principles and show you examples of their application.
SOLID is an acronym for the first five principles of object-oriented design (OOD) by Robert C. Martin (also known as Uncle Bob):
- S – Single Responsibility Principle
- O – Open-Closed Principle
- L – Liskov Substitution Principle
- I – Interface Segregation Principle
- D – Dependency Inversion Principle
These principles establish practices that help develop software with maintainability and extensibility in mind as the project grows. Applying these principles can also help avoid code smells and support the process of code refactoring, and they are relevant to agile or adaptive software development.
Note: We use PHP in our sample code – but the SOLID principles can apply to different programming languages.
Single-Responsibility Principle
The Single-Responsibility Principle (SRP) states: a class should have only one reason to change, which means that a class should have only one task.
For example, we can consider an application that receives a collection of shapes – circles and squares – and calculates the sum of the areas of all the shapes in the collection.
To implement this example, we first create the classes for the shapes and set the required parameters in the constructors.
For squares, we need the side length:
class Square
{
public $length;
public function __construct($length)
{
$this->length = $length;
}
}
For circles we need the radius:
class Circle
{
public $radius;
public function __construct($radius)
{
$this->radius = $radius;
}
}
Next, we create the
AreaCalculator
class and write the logic to sum up the areas of all the shapes provided. The area of a square is calculated by taking the side length squared. The area of a circle is multiplied by π and then taken squared.
class AreaCalculator
{
protected $shapes;
public function __construct($shapes = [])
{
$this->shapes = $shapes;
}
public function sum()
{
foreach ($this->shapes as $shape) {
if (is_a($shape, 'Square')) {
$area[] = pow($shape->length, 2);
} elseif (is_a($shape, 'Circle')) {
$area[] = pi() * pow($shape->radius, 2);
}
}
return array_sum($area);
}
public function output()
{
return implode('', [
'Sum of the areas of the provided shapes: ',
$this->sum(),
]);
}
}
To use the
AreaCalculator
class, you must instantiate the class and pass an array of shapes. The result is displayed at the bottom of the page. Here is an example with a collection of three shapes:
- A circle with a radius of 2
- A square with a side length of 5
- Another square with a side length of 6
$shapes = [
new Circle(2),
new Square(5),
new Square(6),
];
$areas = new AreaCalculator($shapes);
echo $areas->output();
The problem with the
output
method is that the AreaCalculator
handles the logic to output the data. Imagine you want to convert the output to another format like JSON. All the logic would be handled by the AreaCalculator
class, which would violate the Single-Responsibility Principle. The AreaCalculator
class should only care about the sum of the areas of the provided shapes and not care whether the user wants JSON or HTML.
To solve this problem, you can create a separate SumCalculatorOutputter
class and use this new class to handle the logic for outputting the data:
class SumCalculatorOutputter
{
protected $calculator;
public function __construct(AreaCalculator $calculator)
{
$this->calculator = $calculator;
}
public function JSON()
{
$data = [
'sum' => $this->calculator->sum(),
];
return json_encode($data);
}
public function HTML()
{
return implode('', [
'Sum of the areas of the provided shapes: ',
$this->calculator->sum(),
]);
}
}
The
SumCalculatorOutputter
class would work as follows:
$shapes = [
new Circle(2),
new Square(5),
new Square(6),
];
$areas = new AreaCalculator($shapes);
$output = new SumCalculatorOutputter($areas);
echo $output->JSON();
echo $output->HTML();
Now the logic needed to output the data to the user is handled by the
SumCalculatorOutputter
class. This satisfies the single-responsibility principle.
Open-Closed Principle
The Open-Closed Principle (OCP) states: Objects or entities should be open for extensions but closed for modifications. That is, a class should be extensible without modifying the class itself.
Understanding the AreaCalculator class
Let’s look again at the AreaCalculator
class and focus on the sum
method:
class AreaCalculator
{
protected $shapes;
public function __construct($shapes = [])
{
$this->shapes = $shapes;
}
public function sum()
{
foreach ($this->shapes as $shape) {
if (is_a($shape, 'Square')) {
$area[] = pow($shape->length, 2);
} elseif (is_a($shape, 'Circle')) {
$area[] = pi() * pow($shape->radius, 2);
}
}
return array_sum($area);
}
}
Suppose the user wants to calculate the sum of other shapes like triangles, pentagons, hexagons, etc. Then you would have to constantly change this code and add more if/else blocks. This would violate the open-closed principle.
Improving the sum method
A better way to design the sum
method is to remove the logic for calculating the area of each shape from the method of the AreaCalculator
class and append it to the classes of the shapes. Here is the area
method defined in the Square
class:
class Square
{
public $length;
public function __construct($length)
{
$this->length = $length;
}
public function area()
{
return pow($this->length, 2);
}
}
And here is the
area
method defined in the Circle
class:
class Circle
{
public $radius;
public function __construct($radius)
{
$this->radius = $radius;
}
public function area()
{
return pi() * pow($shape->radius, 2);
}
}
The
sum
method for AreaCalculator
can then be rewritten as follows:
class AreaCalculator
{
// ...
public function sum()
{
foreach ($this->shapes as $shape) {
$area[] = $shape->area();
}
return array_sum($area);
}
}
Now you can create another form class and add it when calculating the sum without any code changes.
Ensuring Shape Integrity
However, another problem arises. How do you know that the object passed to the AreaCalculator
is actually a shape, or if the shape has a method called area
? Using an interface is an essential part of SOLID.
Create a ShapeInterface
that supports area
:
interface ShapeInterface
{
public function area();
}
Change your shape classes to implement the
ShapeInterface
. Here is the update for Square
:
class Square implements ShapeInterface
{
// ...
}
And here is the update for
Circle
:
class Circle implements ShapeInterface
{
// ...
}
In the
sum
method for AreaCalculator
you can check if the provided shapes are actually instances of ShapeInterface
. Otherwise, throw an exception:
class AreaCalculator
{
// ...
public function sum()
{
foreach ($this->shapes as $shape) {
if (is_a($shape, 'ShapeInterface')) {
$area[] = $shape->area();
continue;
}
throw new AreaCalculatorInvalidShapeException();
}
return array_sum($area);
}
}
This fulfills the Open-Closed Principle.
Liskov Substitution Principle
The Liskov Substitution Principle (LSP) ensures that any subclass can serve as a substitute for its base class without affecting the correctness of a program.
AreaCalculator and VolumeCalculator Example
Building on the AreaCalculator
example, consider a new class VolumeCalculator
:
class VolumeCalculator extends AreaCalculator
{
public function sum()
{
// Logic to calculate volumes, returning a numerical value
return $summedData;
}
}
When the VolumeCalculator
adheres to LSP, it can be used anywhere an AreaCalculator
is expected without errors.
Interface Segregation Principle
The Interface Segregation Principle (ISP) ensures that a class should not be forced to implement interfaces it does not use.
Separation of Shape Interfaces
interface ShapeInterface
{
public function area();
}
interface ThreeDimensionalShapeInterface
{
public function volume();
}
Using separate interfaces for different responsibilities prevents classes from implementing unnecessary methods, satisfying ISP.
Dependency Inversion Principle
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules, but both should depend on abstractions.
PasswordReminder and Database Connection Example
interface DBConnectionInterface
{
public function connect();
}
class MySQLConnection implements DBConnectionInterface
{
public function connect()
{
// Database connection logic
}
}
class PasswordReminder
{
private $dbConnection;
public function __construct(DBConnectionInterface $dbConnection)
{
$this->dbConnection = $dbConnection;
}
}
By depending on a SOLID interface, the
PasswordReminder
class remains decoupled from database connection details, adhering to DIP. OOP: Mastering Object-Oriented Design