In the projects I work on I often need to build connections with external services. Most of the time, these projects require periodical data synchronization between my application and the third party service. To do this, I usually write Symfony Commands that can be executed manually or through a cronjob. Inside these commands, I send information back to the terminal to show the progress of the synchronization so you can see that something is happening.
To get things up and running, I put everything in a single Symfony’s Command class
that contains all the code needed to perform the task inside the execute() method.
Because this is not a very SOLID approach, I then refactor code from
this method into separate services and classes. I usually end up with a Synchronizer
service that uses a Repository class to store data locally and a Client class
to pull data from a remote source (or vice versa, depending on the requirements).
final class Synchronizer
{
/* ... */
public function synchronize(): void
{
$data = $this->client->loadProducts();
$products = [];
foreach ($data as $row) {
try {
$product = $this->repository->getByExternalId($row['id']);
} catch (ProductNotFound $exception) {
$product = new Product();
$product->setExternalId($row['id']);
}
$product->setTitle($row['title']);
$product->setPrice((float)$row['price']);
$products[] = $product;
}
$this->repository->store(...$products);
}
}
And here we get to the core problem. How can we make sure that this Synchronizer
service can still let the CLI know what it’s doing (and how far it is in the process),
without binding CLI output so much to the service, that it’s unusable from any other
access point (like controllers, event listeners etc)?
First try, using callbacks
At first, I tried adding closures to the synchronize() method of the Synchronizer.
This, however, ended up quite cumbersome as the amount of parameters increased a
lot, with a lot of code in the Command. Also, when calling this method, it
is quite unclear what kind of parameters are expected in each closure.
final class Synchronizer
{
/* ... */
public function synchronize(\Closure $info, \Closure $startProcess, \Closure $advanceProcess, \Closure $stopProcess): void
{
$info('Starting synchronization');
$data = $this->client->loadProducts();
$info(\sprintf('Loaded %d products from remote source.', \count($data)));
$products = [];
$startProcess(\count($data));
foreach ($data as $row) {
$externalID = $row['id'];
try {
$product = $this->repository->getByExternalId($externalID);
} catch (ProductNotFound $exception) {
$product = new Product();
$product->setExternalId($externalID);
}
$product->setTitle($row['title']);
$product->setPrice((float)$row['price']);
$products[] = $product;
$advanceProcess();
}
$stopProcess();
$this->repository->store(...$products);
$info('Done synchronizing');
}
}
The Feedback Class
To avoid the long parameter list code smell, I wrapped
the closure’s functionality in a single class that can be used in the Synchronizer.
The first version of the Feedback class I made took a SymfonyStyle object in
the constructor and used that to format the CLI output nicely.
Because the $feedback variable could be null (when called from a controller or
event listener), it means the synchronize method has to do a lot of null checks:
use App\Feedback\Feedback;
final class Synchronizer
{
/* ... */
public function synchronize(?Feedback $feedback = null): void
{
if ($feedback) {
$feedback->info('Starting synchronization');
}
$data = $this->client->loadProducts();
if ($feedback) {
$feedback->info(\sprintf('Loaded %d products from remote source.', \count($data)));
}
/* ... */
}
}
The fallback: NoFeedback
To not have to check for the existence of $feedback ever time you want to use
it, I created the NoFeedback class as implementation of the null object pattern.
This class has all the same methods, but without any actual executing code.
To keep the namespace clean, I renamed the Feedback class to SymfonyStyleFeedback
and reused Feedback to create an interface out of the previous class.
use App\Feedback\Feedback;
use App\Feedback\NoFeedback;
final class Synchronizer
{
/* ... */
public function synchronize(?Feedback $feedback = null): void
{
if (!$feedback) {
$feedback = new NoFeedback();
}
$data = $this->client->loadProducts();
$feedback->info(\sprintf('Loaded %d products from remote source.', \count($data)));
/* ... */
}
}
Moving Feedback from method to class
Eventually, I moved the Feedback instance used to the service itself instead
of inside the method that uses it. This led to less boilerplate code per method
in the service and a reusable instance in case the service had more than one
method.
use App\Feedback\Feedback;
use App\Feedback\NoFeedback;
final class Synchronizer
{
/**
* @var Repository
*/
private $repository;
/**
* @var Client
*/
private $client;
/**
* @var Feedback
*/
private $feedback;
public function __construct(Repository $repository, Client $client)
{
$this->repository = $repository;
$this->client = $client;
$this->feedback = new NoFeedback();
}
public function setFeedback(Feedback $feedback): void
{
$this->feedback = $feedback;
}
/* ... */
}
Using it yourself
After receiving positive feedback from the community regarding the usefulness of this solution, I requested permission from my employer to open source it as a package and Linku has graciously agreed.
To make sure that Feedback is also usable outside of Symfony projects, I
split the code over two packages:
- The Linku/Feedback package includes three readily usable
implementations of
Feedback:ClosureFeedbackto use a custom closure for each of the methodsLoggerFeedbackto send information to any PSR-3 LoggerChainedFeedbackto allow multipleFeedbackimplementations to be used at the same time
- The Linku/Feedback-SymfonyStyle package includes a single
implementation that uses
SymfonyStyleto style output to the CLI.
Give Feedback a try. If you have any requests, questions or improvements, please
open an issue or pull request in Github or reach out to me on Twitter.