Every day we are confronted with the problem of duplication. When two functional pieces of software that are not related happen to share the same behaviour. Several solutions exist to address this issue and make code as reusable and flexible as possible.
Today, I want to present you with a hierarchy of these solutions and their benefits. Starting from level zero, which I consider the ugliest, up to level six, the most elegant and abstract (and therefore, complex):
Let's dive into it!
Lv0 - Copy/paste is better than nothing
class Car
{
function go()
{
echo "aha, engine goes brrrrr";
}
}
class GarageDoor
{
function open()
{
echo "aha, engine goes brrrrr";
}
}
When time is a scarce resource, you may want to copy and paste code around to meet deadlines. It's ugly, but hey, if it works, it works! An ugly software is better than no software, after all.
Lv1 - Functional inheritance is better than copy/paste
class Vehicle
{
function startEngine()
{
echo "aha, engine goes brrrrr";
}
}
class Car extends Vehicle
{
function go()
{
parent::startEngine();
}
}
class GarageDoor extends Vehicle
{
function open()
{
parent::startEngine();
}
}
It's still hideous, in my opinion, but at least the code is not duplicated.
The first problem here is that not all vehicles have engines (a bike, for example, has none). Hence, as the codebase grows, the Vehicle class is going to have more and more responsibilities (and more if
statements), eventually becoming a God Object.
Also, we can see the problem with GarageDoor; just because it has an engine doesn't make it a vehicle.
Lv2 - Traits & mixins are better than functional inheritance
trait HasEngine
{
function startEngine()
{
echo "aha, engine goes brrrrr";
}
}
class Car
{
use HasEngine;
function go()
{
$this->startEngine();
}
}
class GarageDoor
{
use HasEngine;
function open()
{
$this->startEngine();
}
}
Traits & mixins are a form a horizontal inheritance which gives you more flexibility. Think of it as * pieces of equipment* for your classes, just like you would equip a sword and armour to a video game character.
It's a lot better than functional inheritance because you can have different traits & mixins for different use-cases, eliminating the accumulation of responsibilities in the same class. The only problem with this approach is that it's not dynamic: you may not be able to swap traits & mixins at runtime.
Lv3 - Static services are better than traits & mixins
class EngineService
{
static function startEngine()
{
echo "aha, engine goes brrrrr";
}
}
class Car
{
function go()
{
EngineService::startEngine();
}
}
class GarageDoor
{
function open()
{
EngineService::startEngine();
}
}
Another issue with traits & mixins is that the methods they expose become part of the class they're equipped on. So it's easy to violate the Single Responsibility Principle when using them.
Using static services solves this problem. Now the Car and GarageDoor class can choose which method they want to expose and simply delegate the behaviour to EngineService.
The downside of this approach is the introduction of static coupling. When you have too much of it across your app, code becomes rigid, and it's challenging to make changes without introducing regressions.
Lv4 - A service container is better than static services
class Engine
{
function start()
{
echo "aha, engine goes brrrrr";
}
}
class Container
{
function makeEngine(): Engine
{
return new Engine;
}
}
class Car
{
function __construct(
private Container $container
) {}
function go()
{
$this->container->makeEngine()->start();
}
}
class GarageDoor
{
function __construct(
private Container $container
) {}
function open()
{
$this->container->makeEngine()->start();
}
}
Using the Registry Design Pattern, we are now able to define our hierarchy of dependencies dynamically by swapping the container (or the container's configuration, which is more common.)
It's fundamentally the same thing as static services, except it's not hardcoded. Thus we can define several strategies of behaviour for different contexts. This approach also makes testing things a lot easier because you can mock the services built by the container.
For instance, you could have a mailer service that sends actual emails defined in a ProductionContainer class and a fake mailer that logs them instead, defined in LocalContainer.
Lv5 - Dependency injection is better than a service container
class Engine
{
function start()
{
echo "aha, engine goes brrrrr";
}
}
class Car
{
public function __construct(
private Engine $engine
) {}
public function go()
{
$this->engine->start();
}
}
class GarageDoor
{
public function __construct(
private Engine $engine
) {}
public function open()
{
$this->engine->start();
}
}
Injecting the service container has one major flaw: it makes all the classes in your system depend on it. Therefore, it lowers the reusability of your classes and components in other contexts. It also forces you to write adapters for every library you want to use because chances are they aren't fit for your specific container.
The service injection approach makes the class dependant on the delegation services. It gives you a lot of flexibility at the cost of more glue-code to tie those implementations together. Most modern framework containers are, in fact, able to automatically infer the dependency chain for you when you build an object, so it's not that big of a deal in reality.
Lv6 - Dependency inversion is better than dependency injection
interface EngineInterface
{
function start();
}
class Engine implements EngineInterface
{
function start()
{
echo "aha, engine goes brrrrr";
}
}
class Car
{
function __construct(
private EngineInterface $engine
) {}
function go()
{
$this->engine->start();
}
}
class GarageDoor
{
function __construct(
private EngineInterface $engine
) {}
function open()
{
$this->engine->start();
}
}
This approach is the same as injecting a concrete service but with a subtle nuance: the required type on Car and GarageDoor constructors is an interface.
While it is true that you could always provide a subtype of Engine, the implementation of Engine would shape its child classes. Therefore, you would have to override a lot of stuff in those child classes to modify inherited behaviour, which is sub-optimal.
This problem doesn't exist with an interface precisely because there is no implementation to override. Instead, you provide the intended implementation while retaining the shape of the interface (its methods prototypes).
It is the essence of the Dependency Inversion Principle that states:
- High-level modules (like Car) should not depend on low-level modules (like Engine). Both should depend on abstractions (like EngineInterface).
- Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
Which level should I use?
As with everything in programming, there is no straight do or don't. See, a system only using the 6th level, Dependency Inversion, is undoubtedly very elegant and robust, but it's also very complex and verbose. Sometimes, a static service can do a better job. I want to remind the #1 rule of Design here: don't overdo it.
I encourage you to see all those solutions as complementary. Except for level zero (copy/paste), please avoid doing that as much as you can. Pretty please 🙏
Would you suggest an improvement to this list?
Please leave a comment below to tell me what you think, and don't forget to like the article. It keeps me motivated to write more. 😅
Top comments (2)
The simplicity of explanations on here is top notch, especially the DIs.
Thank you for your feedback. I really believe in simplicitity 😊