Interactor Pattern (Interactor, Operation)

  • Tutorial

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:


  • Using the "Interactors" pattern.
  • TDD demonstration \ BDD.

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:


  • The first part of the guide is missed with the initialization of the project, a description of the features of the framework and similar specific things.
  • ORM Eloquent is spanned by a globe and also plays the role of a repository.
  • Steps to generate code and templates for sending email.

Saved and focused on:


  • Interactors - the implementation of the minimum interface is done.
  • Tests, step by step development through TDD.

The first part of the original Hanami tutorial which will be referenced by the text below.
Original text of the tutorial on interactor
Link to the repository with the 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 quickly confuses, since several callbacks for one 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: __constructand 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 app 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/bookshelfbecause 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 an interactor AddBookand write a new test tests/bookshelf/interactors/AddBookTest.php:


# tests/bookshelf/interactors/AddBookTest.php<?phpuseLib\Bookshelf\Interactors\AddBook;
classAddBookTestextendsTestCase{        
   privatefunctioninteractor(){
       return$this->app->make(AddBook::class);
   }
   privatefunctionbookAttributes(){
       return [
           "author" => "James Baldwin",
           'title' => "The Fire Next Time",
       ];
   }
   privatefunctionsubjectCall(){
       return$this->interactor()($this->bookAttributes());
   }
   publicfunctiontestSucceeds(){
       $result = $this->subjectCall();
       $this->assertTrue($result->successful());
   }
}

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


<?phpnamespaceLib\Bookshelf\Interactors;
useLib\Interactor\Interactor;
classAddBook{
   useInteractor;
   publicfunction__construct(){
   }
   protectedfunctioncall(){
   }
}

There are only two methods that this class should contain: __constructfor setting data and callfor implementing a 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 integrator AddBookreally does something!


Book creation


Change tests/bookshelf/interactors/AddBookTest.php:


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

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


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

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


<?phpnamespaceLib\Bookshelf\Interactors;
useLib\Interactor\Interactor;
useLib\Bookshelf\Book;
classAddBook{
   useInteractor;
   protectedstatic $expose = ["book"];
   private $book = null;
   publicfunction__construct(){
   }
   protectedfunctioncall($bookAttributes){
       $this->book = new Book($bookAttributes);
   }
}

Two important things should be noted here:


The string protected static $expose = ["book"];adds a property bookto the result object that will be returned when the interaction is called.


The method callassigns a model to a Bookproperty bookthat will be available as a result.


Now the tests must pass.


We initialized the model Book, 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 ours BookRepositoryto save it.


// tests/bookshelf/interactors/AddBookTest.phppublicfunctiontestPersistsBook(){
   $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 method callin the interactor file lib/bookshelf/interactors/AddBook.php:


protectedfunctioncall($bookAttributes){
   $this->book = Book::create($bookAttributes);
}

Instead of calling new Book, we do Book::createwith 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.phppublicfunction__construct(Book $repository){
   $this->repository = $repository;
}
protectedfunctioncall($bookAttributes){
   $this->book = $this->repository->create($bookAttributes);
}

In essence, this is the same thing, with a bit more code to create a property repository.


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


This is an implementation detail.


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


Let's change the test testPersistsBook:


// tests/bookshelf/interactors/AddBookTest.phppublicfunctiontestPersistsBook(){
   $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:


<?phpuseLib\Bookshelf\Mail\BookAddedNotification;
useIlluminate\Support\Facades\Mail;
classBookAddedNotificationTestextendsTestCase{
   publicfunctionsetUp(){
       parent::setUp();
       Mail::fake();
       $this->mail = new BookAddedNotification();
   }
   publicfunctiontestCorrectAttributes(){
       $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 a notification class lib/Bookshelf/Mail/BookAddedNotification.php:


<?phpnamespaceLib\Bookshelf\Mail;
useIlluminate\Mail\Mailable;
useIlluminate\Queue\SerializesModels;
classBookAddedNotificationextendsMailable{
   useSerializesModels;
   publicfunctionbuild(){
       $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 interactor AddBook.


Edit the test AddBookto make sure that the mailer will be called:


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

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


Now we integrate sending notifications to the interactor.


publicfunction__construct(Book $repository, BookAddedNotification $mail){
   $this->repository = $repository;
   $this->mail = $mail;
}
protectedfunctioncall($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 the interactor from the action.


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


<?phpnamespaceApp\Http\Controllers;
useLib\Bookshelf\Interactors\AddBook;
useIlluminate\Http\Request;
useIlluminate\Http\Response;
classBooksCreateControllerextendsController{   
   /**
    * Create a new controller instance.
    *
    * @return void
    */publicfunction__construct(AddBook $addBook){
       $this->addBook = $addBook;
   }
   publicfunctioncall(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 delete the mention on BookRepositorythe tests and use the mock for our interactive AddBook:


<?phpuseLib\Bookshelf\Interactors\AddBook;
classBooksCreateControllerTestextendsTestCase{
   publicfunctiontestCallsInteractor(){
       $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 http parameters of the 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


Also popular now: