Mastering the ‘Adapter’ Design Pattern in Symfony
The Adapter Design Pattern is one of the design patterns introduced in the book ‘Design Patterns,’ written by the Gang of Four. The authors of this book are widely regarded as authorities in the field of software engineering.
The Adapter pattern is a structural design pattern, and its purpose is to convert the interface of a class into another interface that clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.
Adapter consist of 4 participants:
- Client
- Target
- Adapter
- Adaptee
In PHP implementation Target is an interface whose implementations is used by Client. Adaptee is an class which we want to use by client but it does not implements Target interface, Adapter is a class which will make this possible.
In this story, I will try to explain, using my own example, how to implement and incorporate decorator design pattern into Symfony.
Adapter pattern is widely used in Symfony, for example when you are using cache component, you have many adapters to use:
Example
For the adapter example, let’s consider some hypothetical logger system. While this example may seem a bit simplistic, it serves to illustrate the concept of the adapter pattern and demonstrates how and why it is utilized in Symfony.
In our application, we have multiple services where logging is required, and for this purpose, we’ll utilize imaginary bundles. At the initial stage of the application life cycle, we log information into log files using the FileLogger
class from the fictional 'FileLoggerBundle'.
Target participant
The FileLogger
within the FileLoggerBundle bundle employs an interface for logging, which is also an imaginary interface defined in a PSR package. This interface serves as the target participant in our Adapter implementation:
<?php
//Target participant in "adapter implementation"
namespace App\Service\AdapterExample;
interface LoggerInterface
{
public function log($level, string $message, array $context = []): void;
}
FileLogger implementation for logging in FileLoggerBundle:
<?php
namespace App\Service\AdapterExample;
class FileLogger implements LoggerInterface
{
public function log($level, string $message, array $context = []): void
{
// here is a code for logging into the files. Config where to log is defined in parameters
}
}
Client participant
Now, across various services, we have code for logging, and this code essentially acts as the client participant in our Adapter implementation:
<?php
//Client participant in "adapter implementation"
namespace App\Service\AdapterExample;
class OrderService
{
public function __construct(private readonly LoggerInterface $logger)
{
}
public function placeOrder()
{
// something...
$this->logger->log('Level 1', 'User #1 made an Order #1');
// something...
}
}
The FileLogger
is injected where LoggerInterface
is typehinted, because we defined that in our services.yaml
configuration:
services:
#something...
App\Service\AdapterExample\LoggerInterface: '@App\Service\AdapterExample\FileLogger'
We inject LoggerInterface
rather than FileLogger
to facilitate effortless changes to the logger implementation in the future.
The code executed in OrderService
currently calls methods from the FileLogger
.
Later, we discover that previewing log files stored as files is challenging, prompting us to change the logger from FileLogger
to SentryLogger
.
The SentryLogger
is another service from the hypothetical bundle 'SentryLoggerBundle.' Upon inspection, we discover that this implementation also uses the same interface defined in PSR, just like the 'FileLoggerBundle’ :
<?php
namespace App\Service\AdapterExample;
class SentryLogger implements LoggerInterface
{
public function log($level, string $message, array $context = []): void
{
// here is a code for logging into the sentry. Config where to log is defined in parameters
}
}
Consequently, the only change required is to update the configuration from:
services:
#something...
App\Service\AdapterExample\LoggerInterface: '@App\Service\AdapterExample\FileLogger'
to:
services:
#something...
App\Service\AdapterExample\LoggerInterface: '@App\Service\AdapterExample\SentryLogger'
Now, the application will begin sending logs to Sentry without any changes in the code. Of course, configuration adjustments are needed, such as providing the Sentry secret, but these are handled in the configuration — leaving the code untouched.
Later, still unsatisfied with the log filtering in Sentry, we decide to change the logging to Elasticsearch. We discover a suitable imaginary bundle, ‘ElasticsearchLoggerBundle,’ for this purpose.
The challenge arises because the ElasticsearchLogger
from the imaginary bundle does not implement the LoggerInterface
from PSR, as the FileLogger
and SentryLogger
classes do. Instead, it adheres to a similar but different interface, named LoggerNewInterface
from PSR:
<?php
namespace App\Service\AdapterExample;
interface LoggerNewInterface
{
public function log($level, string $message, string $description, array $context = []): void;
}
Please note that LoggerNewInterface
includes $description
in its interface definition. This interface is not compatible with the LoggerInterface
already used in our code, as it lacks the $description
attribute.
So, what can we do?
We could clone that bundle, add the LoggerInterface
interface inside ElasticsearchLogger
, and implement that method. However, that approach is obviously not advisable, and we will refrain from doing so.
What else can we do?
We could change all our services where LoggerInterface
is typehinted and switch to using LoggerNewInterface
. However, this approach is also impractical; we do not want to modify our existing code, and we wish to retain the flexibility to easily switch back to using a File or Sentry logger in the future.
What can we do?
Of course we will use Adapter to overcome that problem.
Previously, you saw the client and target participants in the adapter implementation, and now you will see the other two.
Adaptee participant
Our adaptee participant will be the ElasticsearchLogger
from that imaginary bundle:
<?php
//Adaptee participant in "adapter implementation"
namespace App\Service\AdapterExample;
class ElasticsearchLogger implements LoggerNewInterface
{
public function log($level, string $message, string $description, array $context = []): void
{
// here is a code for logging into the elasticseach. Config where to log is defined in parameters
}
}
Adapter participant
Since the Adaptee (ElasticsearchLogger
) cannot be directly injected into our services where LoggerInterface
is typehinted, we require an adapter to facilitate this integration:
<?php
//Adapter participant in "adapter implementation"
namespace App\Service\AdapterExample;
class ElasticsearchLoggerAdapter implements LoggerInterface
{
public function __construct(private readonly ElasticsearchLogger $logger)
{
}
public function log($level, string $message, array $context = []): void
{
$this->logger->log($level, $message, '', $context);
}
}
In the constructor, the adapter receives the Adaptee (ElasticsearchLogger
). Simultaneously, it implements the Target (LoggerInterface
), allowing it to be injected where LoggerInterface
is typehinted. Additionally, it can call code from the Adaptee (ElasticsearchLogger
) within the log()
function as it does.
Now in the services we will change this configuration:
services:
#something...
App\Service\AdapterExample\LoggerInterface: '@App\Service\AdapterExample\SentryLogger'
to:
services:
#something...
App\Service\AdapterExample\LoggerInterface: '@App\Service\AdapterExample\ElasticsearchLoggerAdapter'
In the services, we updated the configuration to utilize the Elasticsearch logger. However, instead of using the service or interface directly, we use the adapter.
ElasticsearchLoggerAdapter implements LoggerInterface, so we can inject that into our service where LoggerInterface is typehinted.
“When we call the log()
method of ElasticsearchLoggerAdapter
, it, in turn, invokes the log()
method from ElasticsearchLogger
.
What we did was adapt ElasticsearchLogger
from the bundle to make it possible to invoke it with a different interface than it originally implements.
We cannot add a custom description to Elasticsearch log entries from the code, and for all Elasticsearch log entries, an empty string is added. However, this is acceptable to us as a reasonable compromise. After all, we did not send a description to the logs in previous logger implementations.
—
That’s all I hope you enjoyed!