How to create a framework-agnostic application in PHP (2/3)
In the previous article, we’ve prepared a framework-agnostic part of our application. Now it’s time to connect it with a framework and storage. For the purpose of this article, we will use the following tools: Laravel 5.7, Eloquent (MySQL 5.7) and Tactician (command bus implementation). All aforementioned tools are a part of our infrastructure – they are external services we want to use because they will make certain things easier for us. However, business logic shouldn’t care much about them. Instead, we use repository contracts and application layer concepts.
Let’s begin with storage
I decided that in this specific case I’m gonna use a MySQL database. No specific reasons behind that decision – it’s just very popular, most programmers know it pretty well and use it very often.
For starters, we need to prepare Eloquent representation of our domain models. Let’s take a look at domain Runner model one more time:
<?php | |
declare(strict_types = 1); | |
namespace Domain\Model; | |
use Common\Id; | |
use Domain\Exception\RunAlreadyParticipated; | |
use Domain\Exception\RunAlreadyStarted; | |
use Domain\Exception\RunNotParticipated; | |
use Domain\Exception\RunResultAlreadySaved; | |
use Domain\Exception\RunResultExpired; | |
use Domain\Exception\TimeLimitReached; | |
final class Runner extends User | |
{ | |
const RUN_RESULT_EXPIRY_DAYS = 5; | |
/** | |
* @var RunParticipation[] | |
*/ | |
private $participations; | |
/** | |
* @var RunResult[] | |
*/ | |
private $results; | |
public function __construct( | |
Id $id, | |
string $email, | |
string $password, | |
array $participations = [], | |
array $results = [] | |
) { | |
$this->participations = $participations; | |
$this->results = $results; | |
parent::__construct( | |
$id, | |
$email, | |
$password | |
); | |
} | |
/** | |
* @return RunParticipation[] | |
*/ | |
public function getParticipations(): array | |
{ | |
return $this->participations; | |
} | |
/** | |
* @return RunResult[] | |
*/ | |
public function getResults(): array | |
{ | |
return $this->results; | |
} | |
/** | |
* @return RunParticipation | |
* @throws RunAlreadyParticipated | |
* @throws RunAlreadyStarted | |
*/ | |
public function participate(Run $run): RunParticipation | |
{ | |
if (isset($this->participations[(string)$run->getId()])) { | |
throw RunAlreadyParticipated::forRun($run, $this); | |
} | |
if ($run->getStartAt() < new \DateTime()) { | |
throw RunAlreadyStarted::forRun($run); | |
} | |
$runParticipation = new RunParticipation($run, $this->getId()); | |
$this->participations[] = $runParticipation; | |
return $runParticipation; | |
} | |
/** | |
* @throws RunNotParticipated | |
* @throws RunResultAlreadySaved | |
* @throws RunResultExpired | |
* @throws TimeLimitReached | |
*/ | |
public function result(Run $run, int $time): RunResult | |
{ | |
if (!isset($this->participations[(string)$run->getId()])) { | |
throw RunNotParticipated::forRun($run, $this); | |
} | |
if ($run->getStartAt()->diff(new \DateTime())->d > self::RUN_RESULT_EXPIRY_DAYS) { | |
throw RunResultExpired::forRun($run, $this); | |
} | |
if ($time > $run->getTimeLimit()) { | |
throw TimeLimitReached::forRun($run, $this); | |
} | |
if (isset($this->results[(string)$run->getId()])) { | |
throw RunResultAlreadySaved::forRun($run, $this); | |
} | |
$runResult = new RunResult($run, $this->getId(), $time); | |
$this->results[(string)$run->getId()] = $runResult; | |
return $runResult; | |
} | |
} |
And now at its Eloquent equivalent:
<?php | |
namespace Infrastructure\Eloquent\Model; | |
use Illuminate\Database\Eloquent\Model; | |
use Infrastucture\Eloquent\Model\RunResult; | |
class Runner extends Model | |
{ | |
/** | |
* The attributes that are mass assignable. | |
* | |
* @var array | |
*/ | |
protected $fillable = [ | |
‘id‘, | |
‘email‘, | |
‘password‘, | |
]; | |
/** | |
* The attributes that should be hidden for arrays. | |
* | |
* @var array | |
*/ | |
protected $hidden = [ | |
‘password‘ | |
]; | |
/** | |
* @var string | |
*/ | |
protected $table = ‘runners‘; | |
/** | |
* @var string | |
*/ | |
protected $keyType = ‘string‘; | |
/** | |
* @var bool | |
*/ | |
public $timestamps = false; | |
/** | |
* @var bool | |
*/ | |
public $incrementing = false; | |
public function participations() | |
{ | |
return $this->hasMany(RunParticipation::class); | |
} | |
public function results() | |
{ | |
return $this->hasMany(RunResult::class); | |
} | |
} |
Notice that Eloquent model contains relations to other storage models. We’ll get back to them later.
Now there’s one critical question: how to connect these two completely different classes? Since they are not similar at all, extending may not really be a good idea, especially because the storage model already extends ORM’s Model class. Well, we have another option. Let’s write a class that will be able to do both directions translation between these two models. Let’s call it a transformer. Here it is:
<?php | |
declare(strict_types = 1); | |
namespace Infrastructure\Eloquent\Transformer; | |
use Infrastructure\Eloquent\Model\Runner as Entity; | |
use Domain\Model\Runner as Domain; | |
class RunnerTransformer | |
{ | |
private $runParticipationTransformer; | |
private $runResultTransformer; | |
public function __construct( | |
RunParticipationTransformer $runParticipationTransformer, | |
RunResultTransformer $runResultTransformer | |
) { | |
$this->runParticipationTransformer = $runParticipationTransformer; | |
$this->runResultTransformer = $runResultTransformer; | |
} | |
/** | |
* @throws \Common\Exception\InvalidIdException | |
* @throws \Domain\Exception\InvalidRunType | |
*/ | |
public function entityToDomain(Entity $entity): Domain | |
{ | |
$dbParticipations = $entity->participations()->get(); | |
$dbResults = $entity->results()->get(); | |
$runnerId = \Common\Id::create($entity->id); | |
$participations = $this->runParticipationTransformer->entityToDomainMany($dbParticipations); | |
$results = $this->runResultTransformer->entityToDomainMany($dbResults); | |
return new Domain($runnerId, $entity->email, $entity->password, $participations, $results); | |
} | |
public function domainToEntity(Domain $domain): Entity | |
{ | |
$entity = new Entity(); | |
$entity->id = (string)$domain->getId(); | |
$entity->email = $domain->getEmail(); | |
$entity->password = $domain->getPassword(); | |
return $entity; | |
} | |
} |
For now, let’s omit the additional transformers which are injected to handle the other models needed by our runner. We want to focus on methods entityToDomain and domainToEntity. As you can see, thanks to transformer class we can easily connect our domain models with Eloquent and our storage will work just fine.
One more example of Eloquent model based on RunParticipation can be found below.
<?php | |
namespace Infrastructure\Eloquent\Model; | |
use Illuminate\Database\Eloquent\Model; | |
class RunParticipation extends Model | |
{ | |
/** | |
* The attributes that are mass assignable. | |
* | |
* @var array | |
*/ | |
protected $fillable = [ | |
‘runner_id‘, | |
‘run_id‘, | |
]; | |
/** | |
* @var string | |
*/ | |
protected $table = ‘run_participations‘; | |
/** | |
* @var string | |
*/ | |
protected $keyType = ‘string‘; | |
/** | |
* @var bool | |
*/ | |
public $timestamps = false; | |
/** | |
* @var bool | |
*/ | |
public $incrementing = false; | |
public function runner() | |
{ | |
return $this->belongsTo(Runner::class); | |
} | |
public function run() | |
{ | |
return $this->belongsTo(Run::class); | |
} | |
} |
And now the corresponding transformation class:
<?php | |
declare(strict_types = 1); | |
namespace Infrastructure\Eloquent\Transformer; | |
use Illuminate\Database\Eloquent\Collection; | |
use Infrastructure\Eloquent\Model\RunParticipation as Entity; | |
use Domain\Model\RunParticipation as Domain; | |
class RunParticipationTransformer | |
{ | |
private $runTransformer; | |
public function __construct(RunTransformer $runTransformer) | |
{ | |
$this->runTransformer = $runTransformer; | |
} | |
/** | |
* @throws \Common\Exception\InvalidIdException | |
* @throws \Domain\Exception\InvalidRunType | |
*/ | |
public function entityToDomain(Entity $entity): Domain | |
{ | |
$dbRun = $entity->run()->get()->pop(); | |
$runnerId = \Common\Id::create($entity->runner_id); | |
$run = $this->runTransformer->entityToDomain($dbRun); | |
return new Domain($run, $runnerId); | |
} | |
/** | |
* @throws \Common\Exception\InvalidIdException | |
* @throws \Domain\Exception\InvalidRunType | |
*/ | |
public function entityToDomainMany(Collection $entities): array | |
{ | |
$domains = []; | |
foreach ($entities as $entity) { | |
$domains[$entity->run_id] = $this->entityToDomain($entity); | |
} | |
return $domains; | |
} | |
public function domainToEntity(Domain $domain): Entity | |
{ | |
$run = $domain->getRun(); | |
$entity = new Entity(); | |
$entity->run_id = (string)$run->getId(); | |
$entity->runner_id = (string)$domain->getRunnerId(); | |
return $entity; | |
} | |
} |
As you can see, we’ve got additional entityToDomainMany method. It’s used to translate a whole collection of RunParticipation for a specific runner.
Now we have the possibility to translate models, so we can finally fulfil repository contract. We can use facedes without worrying about coupling concerns because everything is separated. Let’s take a look at RunnerRepository implementation:
<?php | |
declare(strict_types = 1); | |
namespace Infrastructure\Eloquent\Repository; | |
use Common\Id; | |
use Domain\Exception\RunnerNotFound; | |
use Domain\Model\Runner; | |
use Infrastructure\Eloquent\Transformer\RunnerTransformer; | |
class RunnerRepository implements \Domain\Repository\RunnerRepository | |
{ | |
private $runnerTransformer; | |
public function __construct(RunnerTransformer $runnerTransformer) | |
{ | |
$this->runnerTransformer = $runnerTransformer; | |
} | |
/** | |
* {@inheritdoc} | |
*/ | |
public function getById(Id $runnerId): Runner | |
{ | |
$runner = \Infrastructure\Eloquent\Model\Runner::find((string)$runnerId); | |
if (null === $runner) { | |
throw RunnerNotFound::forId($runnerId); | |
} | |
return $this->runnerTransformer->entityToDomain($runner); | |
} | |
} |
It finds data in MySQL and uses a transformer to translate it to the domain’s Runner model.
We can also check repository which is used to save data instead of retrieving it:
<?php | |
declare(strict_types = 1); | |
namespace Infrastructure\Eloquent\Repository; | |
use Domain\Model\RunParticipation; | |
use Infrastructure\Eloquent\Transformer\RunParticipationTransformer; | |
class RunParticipationRepository implements \Domain\Repository\RunParticipationRepository | |
{ | |
private $runParticipationTransformer; | |
public function __construct(RunParticipationTransformer $runParticipationTransformer) | |
{ | |
$this->runParticipationTransformer = $runParticipationTransformer; | |
} | |
/** | |
* {@inheritdoc} | |
*/ | |
public function save(RunParticipation $runParticipation): void | |
{ | |
$dbRunParticipation = $this->runParticipationTransformer->domainToEntity($runParticipation); | |
$dbRunParticipation->save(); | |
} | |
} |
It’s very simple, with just two meaningful lines of code. Thanks to “Single Responsibility Principle” most of these classes are quite small and easy to read.
That’s all when it comes to storage! We have connected our business models to ORM and can read/write real data from an external source.
Framework time!
Now let’s connect everything to Laravel. Our point of connection is the application layer. I decided, that I don’t want to dilly-dally with HTTP – it’s so common and boring that wanted to try something different, so we’ll be sending requests through CLI. Fortunately, it’ll be a lot easier, than storage preparation. We’re going to use dependency injection whenever it’s possible.
We need to use the command bus implementation. I chose Tactician. There are ready-made packages for Laravel, I used joselfonseca/laravel-tactician.
Here is our participation console command:
<?php | |
namespace App\Console\Commands; | |
use Application\Command\EnrollRunnerToRun; | |
use Application\Handler\EnrollRunnerToRunHandler; | |
use Common\Id; | |
use Domain\Exception\DomainException; | |
use Illuminate\Console\Command; | |
use Joselfonseca\LaravelTactician\CommandBusInterface; | |
class RunnerParticipate extends Command | |
{ | |
/** | |
* The name and signature of the console command. | |
* | |
* @var string | |
*/ | |
protected $signature = ‘runner:enroll {runnerId} {runId}‘; | |
/** | |
* The console command description. | |
* | |
* @var string | |
*/ | |
protected $description = ‘Enroll runner to run‘; | |
/** | |
* Create a new command instance. | |
* | |
* @return void | |
*/ | |
public function __construct() | |
{ | |
parent::__construct(); | |
} | |
/** | |
* Execute the console command. | |
* | |
* @return mixed | |
*/ | |
public function handle(CommandBusInterface $commandBus) | |
{ | |
$commandBus->addHandler(EnrollRunnerToRun::class, EnrollRunnerToRunHandler::class); | |
$runnerId = Id::create($this->argument(‘runnerId‘)); | |
$runId = Id::create($this->argument(‘runId‘)); | |
$command = new EnrollRunnerToRun($runnerId, $runId); | |
try { | |
$commandBus->dispatch($command); | |
$this->info(‘Runner enrolled‘); | |
} catch (DomainException $e) { | |
$this->error($e->getMessage()); | |
} | |
} | |
} |
We accept two parameters – runnerId and runId. Handle. The method itself is quite simple:
- line 45 – informing command bus which handler should take care of specified command,
- lines 47-48 – creating Id objects, so they will be handled by application command
- line 50 – instantiating command,
- lines 52-58 – dispatching command through command bus and inform about success. If any business rules won’t be met, we catch DomainException and inform the user what was the issue.
You may ask, why haven’t we validated data earlier by using frameworks validators. The truth is, we should have done it, but we’ve skipped it since this application is simplified. You should always validate your data with framework!. Domain exceptions are the last stand, which we normally shouldn’t reach anyway.
We can’t forget about dependency injection though, so here is provider I used:
<?php | |
namespace App\Providers; | |
use Domain\Repository\RunnerRepository; | |
use Domain\Repository\RunParticipationRepository; | |
use Domain\Repository\RunRepository; | |
use Domain\Repository\RunResultRepository; | |
use Illuminate\Support\ServiceProvider; | |
class RunnerProvider extends ServiceProvider | |
{ | |
public $bindings = [ | |
RunnerRepository::class => \Infrastructure\Eloquent\Repository\RunnerRepository::class, | |
RunRepository::class => \Infrastructure\Eloquent\Repository\RunRepository::class, | |
RunParticipationRepository::class => \Infrastructure\Eloquent\Repository\RunParticipationRepository::class, | |
RunResultRepository::class => \Infrastructure\Eloquent\Repository\RunResultRepository::class, | |
]; | |
/** | |
* Register services. | |
* | |
* @return void | |
*/ | |
public function register() | |
{ | |
// | |
} | |
/** | |
* Bootstrap services. | |
* | |
* @return void | |
*/ | |
public function boot() | |
{ | |
// | |
} | |
} |
We need bindings only for our contracts (interfaces), rest is automatically done by Laravel. After this step and writing some database migrations our application is fully working. Not even a piece of logic is depending on a framework or library.
Framework-agnostic = independence
As you can see, being framework-agnostic is not very difficult, we just need to use patterns consciously and add a bit of out-of-box thinking. To prove that our domain and application layers are really independent, I’m gonna reuse them, this time with Symfony and Doctrine. I will describe the process in the third (and the last) part of this article. In the meantime, please take a look at the repository with a working Laravel application.