Simple Solutions (2) - Mutable versus Immutable Services
Matthias Noback
What’s a simpler solution? A mutable object, or an immutable object? We need to consider at least two types of objects in such a discussion: services, and “other” objects. First, let’s look at services.
Service instantiation
One way in which a service can be mutable is because it’s initialized or configured in multiple steps:
$service = new Service();
$service->setLogger(new FileLogger());
// use the service
Since instantiation is not an atomic thing for this service you can’t really use the service until all its dependencies have been provided via “setters”. As a client of this service, you can’t be sure when that’s the case. There may be other methods that have to be called. You’d have to analyze the class for this. So from the standpoint of the client, this class is certainly not that simple.
From the perspective of the service class, dealing with “setter injection” is also problematic. We’d have to deal with dependencies that may not have been provided:
class Service
{
private ?Logger $logger = null;
public function setLogger(Logger $logger): void
{
$this->logger = $logger;
}
public function doSomething(): void
{
if ($this->logger === null) {
throw new LogicException('...');
}
$this->logger->log('...');
}
}
We can fix this by assigning a dummy replacement, a.k.a. Null object for the logger inside the constructor, that will be used until the real logger has been provided:
class Service
{
private Logger $logger;
public function __construct()
{
$this->logger = new NullLogger();
}
public function setLogger(Logger $logger): void
{
$this->logger = $logger;
}
public function doSomething(): void
{
$this->logger->log('...');
}
}
We simplified things by promoting the type of $logger
from ?Logger
to Logger
and removing its default null
value. We were also able to remove the if
clause from doSomething()
. On the other hand we introduced a dependency on an additional class NullLogger
. It’s a bad dependency since we don’t even need it. We want the real logger to be provided. Also, this solution may work for logging, which may be considered an optional thing anyway, but can’t be generalized in any way.
Maybe you are screaming at your screen already, because there is a much simpler solution, which is to make the service immutable, and declare all of its dependencies as required constructor arguments:
class Service
{
public function __construct(
private Logger $logger
) {
}
public function doSomething(): void
{
$this->logger->log('...');
}
}
$service = new Service(new FileLogger());
// use the service
We definitely gain some simplicity points here: we decrease the lines of code and methods needed for the solution, we decrease the number of method calls needed before we can use the service, and we are offering only a single way of instantiating the service.
I can’t think of any negative points for using constructor injection. One objection we could have is: what if one of the dependencies really is an optional dependency? I’d say there’s no such thing as an optional dependency. I can imagine however that there may be a decision involved. E.g. after doing something, we sometimes want to do this other thing. That sounds like a perfect case for using events and event subscribers (adding some complexity, but keeping the original service simple). Another example that comes to mind is logging again. If logging is optional in some cases, we can move that decision outside the service class itself, and leave it to the place where the service is instantiated. There we can still use that NullLogger
:
if (/* based on some clue */) {
$logger = new FileLogger();
} else {
$logger = new NullLogger();
}
$service = new Service($logger);
By adding an if
we introduce some complexity in the service instantiation logic itself, but the decision about which logger to use is now centralized instead of spread over multiple services.