📜 ⬆️ ⬇️

Clean code architecture and development through testing in PHP


Translation of the article Vitalij Mik Clean PHP

The concept of "clean code architecture" (Clean Code Architecture) introduced Robert Martin in the blog 8light . The meaning of the concept is to create an architecture that does not depend on external influence. Your business logic should not be combined with the framework, database, or the web itself. Such independence gives a number of advantages. For example, during development you will be able to postpone any technical solutions, for example, choosing a framework, engine / database provider. You can also easily switch between different implementations and compare them. But the most important advantage of this approach is that your tests will run faster.

Just think about it. Do you really want to go through routing, load an abstract database level or some ORM witchcraft? Or just execute some code to check (assert) certain results?

I began to study such an architecture and practice creating it because of my favorite framework, Kohana. Its main developer once stopped supporting the code, so my projects were not updated and did not receive security patches. And this meant that I needed to either trust the version that is being developed by the community, or switch to a new framework and rewrite projects entirely.
')
Yes, I could choose another framework. Perhaps symfony 1 or Zend 1. But no matter what I choose, this framework would have changed since then. They are constantly changing and evolving. Composer makes it easy not only to install and replace packages, but also to exclude them (it even has the ability to mark packages as excluded), so making mistakes is quite simple.

In this post, I will show you how to implement clean code into PHP, which allows you to control logic, not depending on external solutions, but having the ability to use them. We will study the issue on the example of creating a simple guestbook application.



This illustration shows the different layers of the application. Internal ones do not know anything about external ones, and all of them interact with each other through interfaces.

The most interesting - in the lower right corner: control flow. The diagram explains how the framework interacts with business logic. The controller transfers data to the input port, the information from which the interactor processes, and the result is transmitted to the output port containing the data for the presenter.

Let's start with the use case layer, because here is our specific application logic. The outer layers, including the controller, are related to the framework.

Please note that all the steps described below can be taken from the repository . They are neatly divided into steps using Git-tags. Just download the necessary step if you want to see how it works.

First test


Usually we start with a user interface. What does a person expect to see in the guest book? Probably, the input form, the records of other visitors, perhaps a navigation bar with the search for pages of records. If the book is empty, the message "There are no entries" may be displayed.

In the first test, we need to check (assert) an empty list of records:

<?php require_once __DIR__ . '/../../vendor/autoload.php'; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testEntriesNotExists() { $request = new FakeViewEntriesRequest(); $response = new FakeViewEntriesResponse(); $useCase = new ViewEntriesUseCase(); $useCase->process($request, $response); $this->assertEmpty($response->entries); } } 

Here I used a slightly different notation compared to Uncle Bob's notation. Interactors are useCase , input ports are request , output ports are response . All useCase contain a method in which there is a type hint for a specific request and response interface.

If we follow the principles of development through testing (test-driven development, TDD) - the red cycle, the green cycle, the refactoring cycle, - the test will not be passed, because the classes do not exist. To pass the test, it is enough to create class files, methods and properties. Since the classes are empty, it is still too early for us to start the refactoring cycle.

Now you need to check the display records

 <?php require_once __DIR__ . '/../../vendor/autoload.php'; use BlackScorp\GuestBook\Fake\Request\FakeViewEntriesRequest; use BlackScorp\GuestBook\Fake\Response\FakeViewEntriesResponse; use BlackScorp\GuestBook\UseCase\ViewEntriesUseCase; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testEntriesNotExists() { $request = new FakeViewEntriesRequest(); $response = new FakeViewEntriesResponse(); $useCase = new ViewEntriesUseCase(); $useCase->process($request, $response); $this->assertEmpty($response->entries); } public function testCanSeeEntries() { $request = new FakeViewEntriesRequest(); $response = new FakeViewEntriesResponse(); $useCase = new ViewEntriesUseCase(); $useCase->process($request, $response); $this->assertNotEmpty($response->entries); } } 

The test failed, we are in the red part of the TDD cycle. To pass, you need to add logic to our useCase .

Outline Logic for useCase


But first, we use type hints as parameters and create interfaces:

 <?php namespace BlackScorp\GuestBook\UseCase; use BlackScorp\GuestBook\Request\ViewEntriesRequest; use BlackScorp\GuestBook\Response\ViewEntriesResponse; class ViewEntriesUseCase { public function process(ViewEntriesRequest $request, ViewEntriesResponse $response){ } } 

Artists work the same way. Instead of drawing the entire picture from start to finish, they first create basic shapes and lines to represent the basis of the future image. And then add all sorts of details to the forms. But first a sketch appears.

We instead use forms and lines, for example, repositories and factories . The repository is an abstract level for retrieving data from the repository. The storage can be a database, file, external API, and even memory.

To view the entries in the guestbook, we need to find these entries in the repository, convert them into views and return them.

 <?php namespace BlackScorp\GuestBook\UseCase; use BlackScorp\GuestBook\Request\ViewEntriesRequest; use BlackScorp\GuestBook\Response\ViewEntriesResponse; class ViewEntriesUseCase { public function process(ViewEntriesRequest $request, ViewEntriesResponse $response){ $entries = $this->entryRepository->findAllPaginated($request->getOffset(), $request->getLimit()); if(!$entries){ return; } foreach($entries as $entry){ $entryView = $this->entryViewFactory->create($entry); $response->addEntry($entryView); } } } 

You might ask, why did you need to convert the Entry entity into a view? The fact is that an entity should not leave the limits of the useCase level. We can find it only with the help of the repository, if necessary, change / copy and put it back into the repository. When we begin to move the entity to the outer layer, it is better to add additional methods to improve the interaction. However, in essence, there must be only the main business logic.

Since we do not yet know what format we need to give the entity, skip this step.

Now I will answer your possible question about the factories. Creating a new instance in a loop:

 $entryView = new EntryView($entry); $response->addEntry($entryView); 

we will break the dependency inversion principle . And if then in the same useCase logic we need another view object, then we will have to rewrite the code. And with the help of the factory, you can easily implement different types with different formatting logic, and the same useCase will be used.

Implementation of external dependencies


We already know the useCase dependencies: $entryViewFactory and $entryRepository . The methods of these dependencies are also known. EntryViewFactory creates a method that gets the EntryEntity , and the EntryRepository has a findAll() method that returns an EntryEntities array. Now you can create interfaces for the methods and apply them to useCase .

EntryRepository looks like this:

 <?php namespace BlackScorp\GuestBook\Repository; interface EntryRepository { public function findAllPaginated($offset,$limit); } 

Then useCase :

 <?php namespace BlackScorp\GuestBook\UseCase; use BlackScorp\GuestBook\Repository\EntryRepository; use BlackScorp\GuestBook\Request\ViewEntriesRequest; use BlackScorp\GuestBook\Response\ViewEntriesResponse; use BlackScorp\GuestBook\ViewFactory\EntryViewFactory; class ViewEntriesUseCase { /** * @var EntryRepository */ private $entryRepository; /** * @var EntryViewFactory */ private $entryViewFactory; /** * ViewEntriesUseCase constructor. * @param EntryRepository $entryRepository * @param EntryViewFactory $entryViewFactory */ public function __construct(EntryRepository $entryRepository, EntryViewFactory $entryViewFactory) { $this->entryRepository = $entryRepository; $this->entryViewFactory = $entryViewFactory; } public function process(ViewEntriesRequest $request, ViewEntriesResponse $response) { $entries = $this->entryRepository->findAllPaginated($request->getOffset(), $request->getLimit()); if (!$entries) { return; } foreach ($entries as $entry) { $entryView = $this->entryViewFactory->create($entry); $response->addEntry($entryView); } } } 

As you can see, the tests still do not pass, because there is no dependency implementation. Create some fake objects:

 <?php require_once __DIR__ . '/../../vendor/autoload.php'; use BlackScorp\GuestBook\Fake\Request\FakeViewEntriesRequest; use BlackScorp\GuestBook\Fake\Response\FakeViewEntriesResponse; use BlackScorp\GuestBook\UseCase\ViewEntriesUseCase; use BlackScorp\GuestBook\Entity\EntryEntity; use BlackScorp\GuestBook\Fake\Repository\FakeEntryRepository; use BlackScorp\GuestBook\Fake\ViewFactory\FakeEntryViewFactory; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testEntriesNotExists() { $entryRepository = new FakeEntryRepository(); $entryViewFactory = new FakeEntryViewFactory(); $request = new FakeViewEntriesRequest(); $response = new FakeViewEntriesResponse(); $useCase = new ViewEntriesUseCase($entryRepository, $entryViewFactory); $useCase->process($request, $response); $this->assertEmpty($response->entries); } public function testCanSeeEntries() { $entities = []; $entities[] = new EntryEntity(); $entryRepository = new FakeEntryRepository($entities); $entryViewFactory = new FakeEntryViewFactory(); $request = new FakeViewEntriesRequest(); $response = new FakeViewEntriesResponse(); $useCase = new ViewEntriesUseCase($entryRepository, $entryViewFactory); $useCase->process($request, $response); $this->assertNotEmpty($response->entries); } } 

Since we have already created interfaces for the repository and view factories, it means that we can embed them in fake classes, and at the same time implement the interfaces for request/response .

Now the repository looks like this:

 <?php namespace BlackScorp\GuestBook\Fake\Repository; use BlackScorp\GuestBook\Repository\EntryRepository; class FakeEntryRepository implements EntryRepository { private $entries = []; public function __construct(array $entries = []) { $this->entries = $entries; } public function findAllPaginated($offset, $limit) { return array_splice($this->entries, $offset, $limit); } } 

A species factory is like this:

 <?php namespace BlackScorp\GuestBook\Fake\ViewFactory; use BlackScorp\GuestBook\Entity\EntryEntity; use BlackScorp\GuestBook\Fake\View\FakeEntryView; use BlackScorp\GuestBook\View\EntryView; use BlackScorp\GuestBook\ViewFactory\EntryViewFactory; class FakeEntryViewFactory implements EntryViewFactory { /** * @param EntryEntity $entity * @return EntryView */ public function create(EntryEntity $entity) { $view = new FakeEntryView(); $view->author = $entity->getAuthor(); $view->text = $entity->getText(); return $view; } } 

You ask, why not just use mocking frameworks to create dependencies? There are two reasons for this:

  1. Using the editor, you can easily create the necessary classes, so frameworks are not needed.
  2. When we start creating an implementation for the framework, we can use these fake classes in the DI container and play with templates without the need for a real implementation.

Now the tests passed, we can do refactoring. In fact, there is nothing to refactor in the useCase class, except in the test class.

Dough refactoring


It will be executed in the same way, simply with a different setup (setup) and verification. We can transfer the initialization of fake classes and useCase processing to the useCase private function.

The test class looks like this:

 <?php require_once __DIR__ . '/../../vendor/autoload.php'; use BlackScorp\GuestBook\Entity\EntryEntity; use BlackScorp\GuestBook\Fake\Repository\FakeEntryRepository; use BlackScorp\GuestBook\Fake\ViewFactory\FakeEntryViewFactory; use BlackScorp\GuestBook\Fake\Request\FakeViewEntriesRequest; use BlackScorp\GuestBook\Fake\Response\FakeViewEntriesResponse; use BlackScorp\GuestBook\UseCase\ViewEntriesUseCase; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testCanSeeEntries() { $entries = [ new EntryEntity('testAuthor','test text') ]; $response = $this->processUseCase($entries); $this->assertNotEmpty($response->entries); } public function testEntriesNotExists() { $entities = []; $response = $this->processUseCase($entities); $this->assertEmpty($response->entries); } /** * @param $entities * @return FakeViewEntriesResponse */ private function processUseCase($entities) { $entryRepository = new FakeEntryRepository($entities); $entryViewFactory = new FakeEntryViewFactory(); $request = new FakeViewEntriesRequest(); $response = new FakeViewEntriesResponse(); $useCase = new ViewEntriesUseCase($entryRepository, $entryViewFactory); $useCase->process($request, $response); return $response; } } 

Independence


Now we can, for example, easily create new tests with invalid entities, move the repository and factory to the setup method, and run the tests with real implementations.

We can also embed a useCase ready for use into a DI container and use it inside the framework. In this case, the logic will not depend on the framework.

In addition, nothing prevents to create another implementation of the repository that will communicate with the external API, for example, and transfer it to useCase . The logic will be independent of the database.

If desired, you can create request/response CLI objects and pass them to the same useCase used inside the controller. In this case, the logic will not be platform dependent.

You can even execute in turn different useCase , each of which changes the response object.

 class MainController extends BaseController { public function indexAction(Request $httpRequest) { $indexActionRequest = new IndexActionRequest($httpRequest); $indexActionResponse = new IndexActionResponse(); $this->getContainer('ViewNavigation')->process($indexActionRequest, $indexActionResponse); $this->getContainer('ViewNewsEntries')->process($indexActionRequest, $indexActionResponse); $this->getContainer('ViewUserAvatar')->process($indexActionRequest, $indexActionResponse); $this->render($indexActionResponse); } } 

Pagination


Add a pagination to our guest book. The test may look like this:

  public function testCanSeeFiveEntries(){ $entities = []; for($i = 0;$i<10;$i++){ $entities[] = new EntryEntity('Author '.$i,'Text '.$i); } $response = $this->processUseCase($entities); $this->assertNotEmpty($response->entries); $this->assertSame(5,count($response->entries)); } 

It will not be passed, so you need to modify the process method in useCase , and at the same time rename the findAll method to findAllPaginated .

 public function process(ViewEntriesRequest $request, ViewEntriesResponse $response){ $entries = $this->entryRepository->findAllPaginated($request->getOffset(), $request->getLimit()); //.... } 

You can now apply new parameters in the interface and the fake repository, as well as add new methods to the request interface.

The findAllPaginated method will change a bit in the repository:

  public function findAllPaginated($offset, $limit) { return array_splice($this->entries, $offset, $limit); } 

It is necessary to transfer the request object to tests. Also for the request object constructor you will need a limit parameter. Thus, we will make setup create a constraint together with a new instance.

  public function testCanSeeFiveEntries(){ $entities = []; for($i = 0;$i<10;$i++){ $entities[] = new EntryEntity(); } $request = new FakeViewEntriesRequest(5); $response = $this->processUseCase($request, $entities); $this->assertNotEmpty($response->entries); $this->assertSame(5,count($response->entries)); } 

Test passed. But you still need to test the ability to view the following five records. To do this, you have to add the setPage method to the request object.

 <?php namespace BlackScorp\GuestBook\Fake\Request; use BlackScorp\GuestBook\Request\ViewEntriesRequest; class FakeViewEntriesRequest implements ViewEntriesRequest{ private $offset = 0; private $limit = 0; /** * FakeViewEntriesRequest constructor. * @param int $limit */ public function __construct($limit) { $this->limit = $limit; } public function setPage($page = 1){ $this->offset = ($page-1) * $this->limit; } public function getOffset() { return $this->offset; } public function getLimit() { return $this->limit; } } 

Using this method, we can test the display of the following five records:

  public function testCanSeeFiveEntriesOnSecondPage(){ $entities = []; $expectedEntries = []; $entryViewFactory = new FakeEntryViewFactory(); for($i = 0;$i<10;$i++){ $entryEntity = new EntryEntity(); if($i >= 5){ $expectedEntries[]=$entryViewFactory->create($entryEntity); } $entities[] =$entryEntity; } $request = new FakeViewEntriesRequest(5); $request->setPage(2); $response = $this->processUseCase($request,$entities); $this->assertNotEmpty($response->entries); $this->assertSame(5,count($response->entries)); $this->assertEquals($expectedEntries,$response->entries); } 

Passed, we can refactor. FakeEntryViewFactory transfer FakeEntryViewFactory to a setup method, and it is ready. The last test class looks like this:

 <?php require_once __DIR__ . '/../../vendor/autoload.php'; use BlackScorp\GuestBook\Entity\EntryEntity; use BlackScorp\GuestBook\Fake\Repository\FakeEntryRepository; use BlackScorp\GuestBook\Fake\Request\FakeViewEntriesRequest; use BlackScorp\GuestBook\Fake\Response\FakeViewEntriesResponse; use BlackScorp\GuestBook\Fake\ViewFactory\FakeEntryViewFactory; use BlackScorp\GuestBook\UseCase\ViewEntriesUseCase; class ListEntriesTest extends PHPUnit_Framework_TestCase { public function testEntriesNotExists() { $entries = []; $request = new FakeViewEntriesRequest(5); $response = $this->processUseCase($request, $entries); $this->assertEmpty($response->entries); } public function testCanSeeEntries() { $entries = [ new EntryEntity('testAuthor', 'test text') ]; $request = new FakeViewEntriesRequest(5); $response = $this->processUseCase($request, $entries); $this->assertNotEmpty($response->entries); } public function testCanSeeFiveEntries() { $entities = []; for ($i = 0; $i < 10; $i++) { $entities[] = new EntryEntity('Author ' . $i, 'Text ' . $i); } $request = new FakeViewEntriesRequest(5); $response = $this->processUseCase($request, $entities); $this->assertNotEmpty($response->entries); $this->assertSame(5, count($response->entries)); } public function testCanSeeFiveEntriesOnSecondPage() { $entities = []; $expectedEntries = []; $entryViewFactory = new FakeEntryViewFactory(); for ($i = 0; $i < 10; $i++) { $entryEntity = new EntryEntity('Author ' . $i, 'Text ' . $i); if ($i >= 5) { $expectedEntries[] = $entryViewFactory->create($entryEntity); } $entities[] = $entryEntity; } $request = new FakeViewEntriesRequest(5); $request->setPage(2); $response = $this->processUseCase($request, $entities); $this->assertNotEmpty($response->entries); $this->assertSame(5, count($response->entries)); $this->assertEquals($expectedEntries, $response->entries); } /** * @param $request * @param $entries * @return FakeViewEntriesResponse */ private function processUseCase($request, $entries) { $repository = new FakeEntryRepository($entries); $factory = new FakeEntryViewFactory(); $response = new FakeViewEntriesResponse(); $useCase = new ViewEntriesUseCase($repository, $factory); $useCase->process($request, $response); return $response; } } 

Completion


We looked at how tests led us to useCase , which led to interfaces, and they led to fake implementations. I repeat that the source code for this publication can be downloaded from Github . Pay attention to the tags denoting different stages.

This tutorial demonstrates how for any new project you can easily apply development through testing and clean code architecture. The main advantage of this approach is the complete independence of logic. This approach also allows the use of third-party libraries.

Source: https://habr.com/ru/post/277543/


All Articles