📜 ⬆️ ⬇️

Interactor Pattern (Interactor, Operation)

This text is an adaptation of the manual part of the Hanami framework under the Laravel framework. What is the interest in this material? It provides a step-by-step description with a demonstration of such things common to programming languages ​​and frameworks as:



Immediately it should be noted that these are not only different frameworks with different ideologies (in particular, as for ORM), but also different programming languages, each of which has its own specific culture and established "bests practics" for historical reasons. Different programming languages ​​and frameworks borrow from each other the most successful solutions, so despite the differences in details, the fundamental things do not differ, unless of course we take PL with an initially different paradigm. It is interesting to compare how one and the same task is solved in different ecosystems.


So, initially we have the Hanami (ruby) framework - a fairly new framework, ideologically more to Symfony, with ORM "on repositories". And the target Laravel \ Lumen (php) framework with Active Record.


In the process of adaptation, the most acute angles were cut:



Saved and focused on:



The first part of the original Hanami tutorial to which the text below will link
Original text of the tutorial on interactors
Link to the repository with an adapted php code at the end of the text.


Interactors


New feature: email notifications


Feature script: As an administrator, when adding a book, I want to receive email notifications.


Since the application does not have authentication, anyone can add a new book. We will specify the administrator's email address through the environment variables.


This is just an example showing when to use the interactors, and in particular how to use the Hanas interactive.


This example can serve as a basis for other functions, such as the administrator confirming new books before publishing them. Or providing users the opportunity to specify an email address to edit the book through a special link.


In practice, you can use interactors to implement any business logic abstracted from the network layer. This is especially useful when you want to combine several things to control the complexity of the code base.


They are used to isolate non-trivial business logic, following the Single Responsibility Principle.


In web applications, they are commonly used from controller actions. By doing so, you divide tasks, business logic objects and interactors, and they will not know anything about the network layer of the application.


Callbacks? We do not need them!


The easiest way to implement an email notification is to add a callback.


That is, after creating a new book entry in the database, an email is sent.


Architecturally Hanami does not provide such a mechanism. This is because we consider callbacks of models as anti-pattern. They violate the principle of sole responsibility. In our case, they incorrectly mix the persistence layer with email notifications.


During testing (and most likely in some other cases), you will want to skip the callback. This is quickly confusing, since several callbacks for a single event can be run in a specific order. In addition, you can skip some callbacks. Callbacks make code fragile and difficult to understand.


Instead, we recommend explicit, instead of implicit.


Interactor is an object that represents a specific use case.


They allow each class to have sole responsibility. Interactor’s sole responsibility is to combine objects and method calls to achieve a certain result.


Idea


The main idea of ​​integrators is that you extract the isolated parts of the functionality into a new class.


You need to write only two public methods: __construct and call .
In the php implementation of the interactor, the call method has a protected modifier and is called via __invoke .


This means that such objects are easy to interpret, since there is only one available method for using them.


Encapsulating behavior in a single object makes it easier to test. It also makes it easier to understand your code base, and not just leaves the hidden complexity in an implicitly expressed form.


Training


Let's say we have our Bookshelf application from Getting Started , and we want to add the e-mail notification feature for an added book.


We write interactive


Let's create a folder for our engineers and a folder for their tests:


 $ mkdir lib/bookshelf/interactors $ mkdir tests/bookshelf/interactors 

We put them in lib/bookshelf because they are not related to the web application. Later you can add books through the admin portal, API, or even the command line utility.


Add the AddBook Interactor and write a new test tests/bookshelf/interactors/AddBookTest.php :


 # tests/bookshelf/interactors/AddBookTest.php <?php use Lib\Bookshelf\Interactors\AddBook; class AddBookTest extends TestCase { private function interactor() { return $this->app->make(AddBook::class); } private function bookAttributes() { return [ "author" => "James Baldwin", 'title' => "The Fire Next Time", ]; } private function subjectCall() { return $this->interactor()($this->bookAttributes()); } public function testSucceeds() { $result = $this->subjectCall(); $this->assertTrue($result->successful()); } } 

Running a test suite will cause the Class does not exist error, because there is no AddBook class. Let's create this class in the file lib/bookshelf/interactors/AddBook.php :


 <?php namespace Lib\Bookshelf\Interactors; use Lib\Interactor\Interactor; class AddBook { use Interactor; public function __construct() { } protected function call() { } } 

There are only two methods that this class should contain: __construct to set up data and call to implement the script.


These methods, especially call , should call private methods that you write.


By default, the result is considered successful, since we clearly did not indicate that the operation failed.


Let's run the test:


 $ phpunit 

All tests must pass!


Now let's make sure that our AddBook does something!


Book creation


Change the tests/bookshelf/interactors/AddBookTest.php :


  public function testCreateBook() { $result = $this->subjectCall(); $this->assertEquals("The Fire Next Time", $result->book->title); $this->assertEquals("James Baldwin", $result->book->author); } 

If you run phpunit tests, you will see an error:


 Exception: Undefined property Lib\Interactor\InteractorResult::$book 

Let's fill in our interactor, then explain what we did:


 <?php namespace Lib\Bookshelf\Interactors; use Lib\Interactor\Interactor; use Lib\Bookshelf\Book; class AddBook { use Interactor; protected static $expose = ["book"]; private $book = null; public function __construct() { } protected function call($bookAttributes) { $this->book = new Book($bookAttributes); } } 

Two important things should be noted here:


String protected static $expose = ["book"]; adds the book property to the result object that will be returned when the interactor is called.


The call method assigns the Book model to the book property, which will be available as a result.


Now the tests must pass.


We initialized the Book model, but it is not stored in the database.


Save book


We have a new book, obtained from the title and the author, but it is not yet in the database.


We need to use our BookRepository to save it.


 // tests/bookshelf/interactors/AddBookTest.php public function testPersistsBook() { $result = $this->subjectCall(); $this->assertNotNull($result->book->id); } 

If you run the tests, you will see a new error with the message Failed asserting that null is not null .


This is because the book we have created does not have an identifier, since it will receive it only when it is saved.


To pass the test, we need to create a saved book. Another, no less correct way is to keep the book that we already have.


Edit the call method in the lib/bookshelf/interactors/AddBook.php :


 protected function call($bookAttributes) { $this->book = Book::create($bookAttributes); } 

Instead of calling new Book , we do Book::create with book attributes.


The method still returns the book, and also saves this entry in the database.


If you run the tests now, you will see that all the tests pass.


Dependency Injection


Let's refactor to use dependency injection.


Tests are still working, but they depend on the features of saving to the database (the id property is determined after successful saving). This is the implementation detail of how persistence works. For example, if you want to create a UUID before saving it and indicate that the save was successful in some other way than filling in the id column, you will have to change this test.


We can change our test and interpreter to make it more reliable: it will be less prone to breakage due to changes outside its file.


Here is how we can use dependency injection in the interactor:


 // lib/bookshelf/interactors/AddBook.php public function __construct(Book $repository) { $this->repository = $repository; } protected function call($bookAttributes) { $this->book = $this->repository->create($bookAttributes); } 

In essence, this is the same, with a bit more code, for creating the repository property.


Right now, the test checks the behavior of the create method, that its identifier is filled with $this->assertNotNull($result->book->id) .


This is an implementation detail.


Instead, we can change the test to just make sure that the repository has the create method called and trust that the repository will save the object (since this is its responsibility).


Let's change the testPersistsBook test:


 // tests/bookshelf/interactors/AddBookTest.php public function testPersistsBook() { $repository = Mockery::mock(Book::class); $this->app->instance(Book::class, $repository); $attributes = [ "author" => "James Baldwin", 'title' => "The Fire Next Time", ]; $repository->expects()->create($attributes); $this->subjectCall($attributes); } 

Now our test does not violate the boundaries of its zone.


All we did was add the dependence of the interactor on the repository.


Email Notification


Let's add a notification email!


You can also do anything here, for example, send an SMS, send a message to a chat or activate a web hook.


We will leave the message body empty, but in the subject field we will indicate “Book added!”.


Create a test for notification tests/bookshelf/mail/BookAddedNotificationTest.php :


 <?php use Lib\Bookshelf\Mail\BookAddedNotification; use Illuminate\Support\Facades\Mail; class BookAddedNotificationTest extends TestCase { public function setUp() { parent::setUp(); Mail::fake(); $this->mail = new BookAddedNotification(); } public function testCorrectAttributes() { $this->mail->build(); $this->assertEquals('no-reply@example.com', $this->mail->from[0]['address']); $this->assertEquals('admin@example.com', $this->mail->to[0]['address']); $this->assertEquals('Book added!', $this->mail->subject); } } 

Add the notification class lib/Bookshelf/Mail/BookAddedNotification.php :


 <?php namespace Lib\Bookshelf\Mail; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; class BookAddedNotification extends Mailable { use SerializesModels; public function build() { $this->from('no-reply@example.com') ->to('admin@example.com') ->subject('Book added!'); return $this->view('emails.book_added_notification'); } } 

Now all our tests pass!


But the notification has not yet been sent. We need to call the dispatch from our AddBook interactor.


Edit the AddBook test to make sure the AddBook will be called:


 public function testSendMail() { Mail::fake(); $this->subjectCall(); Mail::assertSent(BookAddedNotification::class, 1); } 

If we run the tests, we get the error: The expected [Lib\Bookshelf\Mail\BookAddedNotification] mailable was sent 0 times instead of 1 times. .


Now we integrate sending notifications to the interactor.


 public function __construct(Book $repository, BookAddedNotification $mail) { $this->repository = $repository; $this->mail = $mail; } protected function call($bookAttributes) { $this->book = $this->repository->create($bookAttributes); Mail::send($this->mail); } 

As a result, Interactor will send a notification about the addition of the book to e-mail.


Integration with the controller


Finally, we need to call an interactive action.


Edit the action file app/Http/Controllers/BooksCreateController.php :


 <?php namespace App\Http\Controllers; use Lib\Bookshelf\Interactors\AddBook; use Illuminate\Http\Request; use Illuminate\Http\Response; class BooksCreateController extends Controller { /** * Create a new controller instance. * * @return void */ public function __construct(AddBook $addBook) { $this->addBook = $addBook; } public function call(Request $request) { $input = $request->all(); ($this->addBook)($input); return (new Response(null, 201)); } } 

Our tests pass, but there is a small problem.


We double test the book creation code.


As a rule, this is a bad practice, and we can fix it by illustrating another advantage of the engineers.


We are going to remove the reference to BookRepository in tests and use the mock for our AddBook interactive:


 <?php use Lib\Bookshelf\Interactors\AddBook; class BooksCreateControllerTest extends TestCase { public function testCallsInteractor() { $attributes = ['title' => '1984', 'author' => 'George Orwell']; $addBook = Mockery::mock(AddBook::class); $this->app->instance(AddBook::class, $addBook); $addBook->expects()->__invoke($attributes); $response = $this->call('POST', '/books', $attributes); } } 

Now our tests pass and they are much more reliable!


The action accepts input data (from the parameters of the http request) and calls the interactor to do its job. The sole responsibility of the action - work with the network. And the interactor works with our real business logic.


This greatly simplifies the actions and their tests.


Action is almost free from business logic.


When we modify the interactor, we no longer need to change the action or its test.


Note that in a real application you probably want to do more than the above logic, for example, to make sure that the result is successful. And if a failure occurs, you will want to return errors from the interactor.


Code Repository



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