Factory Pattern can encapsulate complexity in PHP

Fernando Castillo
8 min readJun 9, 2024

--

Photo by Simon Kadula on Unsplash

Sometimes I hear comments against the Factory Pattern, meaning that it just introduces complexity with no real value, and I want to explore at least one use case in which I think it’s beneficial.

We can start with a quote from Wikipedia:

In object oriented programming, the factory method pattern is a creational pattern that uses factory methods to deal with the problem of creating objects without having to specify their exact class.

Develop a configurable service with several strategies

We can work from an example in which we want to alert some actor when a Throwable is uncaught in our system.

Talking with the tech lead, when this happens in production, the alert is required to be sent to the developers slack team so anyone can react quickly to the problem, but also to the tech lead email to be aware of what happens and organize further tasks about it.

The qa team is working with an in house ticketing system, and they want that during the tests in the qa environment, alerts are registered as tickets there. Let’s assume that they use a special query parameter or HTTP header in each request with a code that identifies the test they are executing and we have a service to fetch that code use it to relate the error to the test.

Developers working in local environments usually prefer to have the alert written to a local file that they can configure because that’s simpler and does not depend on external tools.

Developers don’t want changes in strategies to require a change in code. Unless another communication must be implemented, any change should be achievable with a modification in the environment variables.

As an aside, code should always be ignorant of the environment it is being executed on. If the code starts having conditions like if ($env === 'prod') or similar, you are in for some complex problems if you need a new environment, and you got yourself the requirement to go through a code change, and probably a code review process to change something that is only a setting for one environment. Or even worse, you could end up maintaining branches with differences based on the environment. That’s its own kind of hell. Code should be identical for a given version no matter the environment.

Alert Service Requirements

So for this scenario, a list of requirements could be like this.

  1. We need to define several communication strategies: slack channel, email, ticketing system and local file.
  2. We need to be able to combine more than one communication strategy.
  3. When a Throwable is uncaught in the system, we want to alert whoever is responsible to attend the issue.
  4. Each environment (development, qa, stage, production) need a different communication strategy.
  5. We need to be able to modify the strategy with a change in the environment variables, not a change in code.

Straightforward Implementation

Here are the interfaces for services that send content to slack, email, a file or our ticketing service, along with a format service that will transform our Throwable into some content that, for simplicity, is ok for all contexts. We can assume we already have implementations for all those services and that they are injected into the one we are going to build.

interface SlackMessenger
{
public function sendToChannel(string $channel, string $message): void;
}

interface EmailSender
{
public function send(string $email, string $subject, string $body): void;
}

interface FilesystemWriter
{
public function write(string $path, string $content): void;
}

interface QaTicketCreator
{
public function create(string $title, string $description): void;
}

interface ThrowableFormatter
{
public function format(Throwable $throwable): string;
}

With all these services we can start with an implementation that just takes them and uses them depending on the environment.

final readonly class AlertThrowableService
{
public function __construct(
private string $env,
private ThrowableFormatter $throwableFormatter,
private SlackMessenger $slackMessenger,
private QaTicketCreator $qaTicketCreator,
private EmailSender $emailSender,
private FilesystemWriter $filesystemWriter,
) {}

public function alert(Throwable $throwable): void
{
$formattedThrowable = $this->throwableFormatter->format($throwable);

switch ($this->env) {
case 'dev':
$this->filesystemWriter->write(sys_get_temp_dir() . '/alert.txt', $throwable);
break;
case 'qa':
$this->qaTicketCreator->create('Alert', $formattedThrowable);
break;
case 'prod':
$this->emailSender->send(
'backend.lead@fictional_company.com',
'Alert',
$formattedThrowable
);
$this->slackMessenger->sendToChannel('alerts', $formattedThrowable);
break;
}
}
}

This covers most requirements: we use several strategies, we can combine them, it can react to uncaught throwables if called from the right place and each environment uses a different communication strategy. What we don’t have yet is a way to change our implementation based on the environment.

Adding Environment into the Service

Up to this point this was easy, we had many services covering the heavy lifting, we just needed to check the environment we are in and use one or more.

To introduce the environment into the service we are going to use another service that will be able to read the environment values. We want to have a defined set of strategies, so let’s capture them in an enum. Then the reader service can have some implementation in which it reads the ALERT_STRATEGY environment variable and explodes it by comma to get an array of strategies.

enum AlertStrategy: string
{
case SLACK = 'slack';
case EMAIL = 'email';
case FILESYSTEM = 'filesystem';
case QA_TICKET = 'qa_ticket';
}
interface EnvironmentReader
{
/** @return AlertStrategy[] */
public function getAlertStrategies(): array;
}

Now we can write something able to take a list of comma separated values from the ALERT_STRATEGY environment variable and use all that are configured there.

final readonly class AlertThrowableService
{
public function __construct(
private EnvironmentReader $environmentReader,
private ThrowableFormatter $throwableFormatter,
private SlackMessenger $slackMessenger,
private QaTicketCreator $qaTicketCreator,
private EmailSender $emailSender,
private FilesystemWriter $filesystemWriter,
) {}

public function alert(Throwable $throwable): void
{
$formattedThrowable = $this->throwableFormatter->format($throwable);

foreach ($this->environmentReader->getAlertStrategies() as $alertStrategy) {
match ($alertStrategy) {
AlertStrategy::SLACK => $this->slackMessenger->sendToChannel('alerts', $formattedThrowable),
AlertStrategy::EMAIL => $this->emailSender->send(
'backend.lead@fictional_company.com',
'Alert',
$formattedThrowable
),
AlertStrategy::FILESYSTEM => $this->filesystemWriter->write(sys_get_temp_dir() . '/alert.txt', $throwable),
AlertStrategy::QA_TICKET => $this->qaTicketCreator->create('Alert', $formattedThrowable),
};
}
}
}

Now we are able to modify the environment variable and change the behavior of the service without any change in the code. Awesome. This still can be improved moving to other environment values all the hardcoded values like the email recipient or the file path. We have an environment reader that could do validations on those so if the environment is wrong the error will point there.

What can be improved

We still have some smells here though. This service has many dependencies, which increases complexity, but in most cases, if not all, some dependencies are not going to be used. If any of these dependencies needed to do some work on construction, like connecting to some resource, that would be done even if the corresponding strategy is not configured. If that fails, we would have an error in our system even if that resource is not used in that environment.

And another one, how would we do if tomorrow we need to add a new Telegram strategy? We need to change this service then, even if we are not changing anything about how it works. This looks like it should better use the Open/Closed principle so adding another strategy doesn’t change the service.

Introduce the Factory

At this point we have some important complexity we need to handle, but we don’t want to have it in an otherwise simple service. What’s the solution then? Throw that complexity into a factory.

A factory can work with a new interface that abstract any kind of communication strategy, and we move each one into a separate implementation. Then the factory can get the EnvironmentReader and use it to build the one implementation that is required. It can also validate the configuration and decide if something is wrong whether to stop execution with an error or just warn and disable an strategy.

The interface could be like this then.

interface NotificationStrategy
{
public function notify(string $message): void;
}

And an example implementation for the Email Strategy.

final readonly class EmailNotificationStrategy implements NotificationStrategy
{
public function __construct(
private string $recipientAddress,
private string $subject,
private EmailSender $emailSender,
) {}

public function notify(string $message): void
{
$this->emailSender->send($this->recipientAddress, $this->subject, $message);
}
}

We can even have a Composite Strategy.

final readonly class CompositeNotificationStrategy implements NotificationStrategy
{
/** @var NotificationStrategy[] */
private array $notificationStrategies;

/** @param NotificationStrategy[] $notificationStrategies */
private function __construct(array $notificationStrategies)
{
$this->notificationStrategies = $notificationStrategies;
}

public static function create(NotificationStrategy ...$notificationStrategies): self
{
return new self($notificationStrategies);
}

public function notify(string $message): void
{
foreach ($this->notificationStrategies as $notificationStrategy) {
$notificationStrategy->notify($message);
}
}
}

Then the service can focus on getting the content and use whatever strategy was created in the factory without other concerns.

final readonly class AlertThrowableService
{
public function __construct(
private ThrowableFormatter $throwableFormatter,
private NotificationStrategy $notificationStrategy,
) {}

public function alert(Throwable $throwable): void
{
$formattedThrowable = $this->throwableFormatter->format($throwable);

$this->notificationStrategy->notify($formattedThrowable);
}
}

Our last step is building the actual factory. I’ll leave the connection of the factory with the service because it will probably depend on your flavor of dependency injection container, but should be relatively easy to do.

Note that the factory is using the container. I think it’s a reasonable place to use it because we are in container context anyways. Doing it this way the strategies will be defined in the container along with every other dependency definition.

final readonly class NotificationStrategyFactory
{
public function __construct(
private EnvironmentReader $environmentReader,
private ContainerInterface $container
) {}

private function buildOne(AlertStrategy $alertStrategy): NotificationStrategy
{
return match ($alertStrategy) {
AlertStrategy::SLACK => $this->container->get(SlackNotificationStrategy::class),
AlertStrategy::EMAIL => $this->container->get(EmailNotificationStrategy::class),
AlertStrategy::FILESYSTEM => $this->container->get(FilesystemNotificationStrategy::class),
AlertStrategy::QA_TICKET => $this->container->get(QaTicketNotificationStrategy::class),
};
}

public function create(string $env): NotificationStrategy
{
$strategies = $this->environmentReader->getAlertStrategies();

// Use a null implementation in case no strategies are resolved
if (!$strategies) {
return new NullNotificationStrategy();
}

// If only one strategy is configured return a single implementation
if (count($strategies) === 1) {
return $this->buildOne($strategies[0]);
}

// For multiple strategies return a composite implementation
return CompositeNotificationStrategy::create(
...array_map(
fn (AlertStrategy $strategy) => $this->buildOne($strategy),
$strategies
)
);
}
}

And now the factory decides how the notification will be by injecting a strategy. At this point if we want to add a new one we don’t touch the service, we add it to the factory, which means the service is open for extension and closed for modification.

In the end, to answer the initial question, if we apply the factory pattern here will we get more complexity? We’ll have objectively more files, more classes and more lines of code. But are those numbers equivalent to complexity? For example, let’s compare the signature of the service with and without the factory.

    public function __construct(
private ThrowableFormatter $throwableFormatter,
private NotificationStrategy $notificationStrategy,
) {}
    public function __construct(
private EnvironmentReader $environmentReader,
private ThrowableFormatter $throwableFormatter,
private SlackMessenger $slackMessenger,
private QaTicketCreator $qaTicketCreator,
private EmailSender $emailSender,
private FilesystemWriter $filesystemWriter,
) {}

The version with no factory has less lines/files, but I find it harder to understand. For me, the constructor of a service should be like the presentation card. This first example tells me “Hello, I format a throwable and use a notification strategy”. Perfect, I get it. The other one is telling me “Hello, I read the environment, format a throwable, talk to slack, create qa tickets, send emails and write in the file system”. It’s much more and you need to read the code to really understand what it’s doing.

This doesn’t mean that the factory pattern can’t be abused though. Using a factory when there is no more than one simple option to build is not useful. Many of these cases can be solved just using the Dependency Container, which might even have an auto-wiring feature, so you may not need to define it explicitly.

--

--