These are Interactors, also referred to service objects, use-cases or operations

We think they’re great and help manage complexity, but you’re free to build a Hanami app without them at all.

In this guide, we’ll explain how Hanami’s Interactors work by adding a small feature to an existing application.

The existing application we’ll work from is the application from the .

A New Feature: Email Notifications

The story for our new feature is:

Since the application doesn’t have authentication, anyone can add a new book. We’ll provide an admin email address via an environment variable.

This is just an example to show when you should use an interactor, and, specifically, how Hanami::Interactor can be used.

This example could provide a basis for other features like adding administrator approval of new books before they’re posted, or allowing users to provide an email address, then edit the book via a special link.

In practice, you can use interactors to implement any business logic, abstracted away from the web. It’s particularly useful for when you want to do several things at once, in order to manage the complexity of the codebase.

They’re used to isolate non-trivial business logic. This follows the

In a web application, they will generally be called from the controller action. This lets you separate concerns. Your business logic objects, interactors, won’t know about the web at all.

Callbacks? We Don’t Need Them!

An easy way of implementing email notification would be to add a callback.

That is: after a new Book record is created in the database, an email is sent out.

By design, Hanami doesn’t provide any such mechanism. This is because we consider persistence callbacks an anti-pattern. They violate the Single Responsibility principle. In this case, they improperly mix persistence with email notifications.

During testing (and at some other point, most likely), you’ll want to skip that callback. This quickly becomes confusing, since multiple callbacks on the same event will be triggered in a specific order. Also, you may want to skip several callbacks at some point. They make code hard to understand, and brittle.

Instead, we recommend being explicit over implicit.

An interactor is an object that represents a specific use-case.

They let each class have a single responsibility. An interactor’s single responsibility is to combine object and method calls in order to achieve a specific outcome.

We provide Hanami::Interactor as a module, so you can start with a Plain Old Ruby Object, and include include Hanami::Interactor when you need some of its features.

Concept

The central idea behind interactors is that you extract an isolated piece of functionality into a new class.

You should only write two public methods: #initialize and #call.

This means objects are easy to reason about, since there’s only one possible method to call after the object is created.

By encapsulating behavior into a single object, it’s easier to test. It also makes your codebase easier to understand, rather than leaving your complexity hidden, only expressed implicitly.

Let’s say we have our bookshelf application, from the and we want to add the ‘email notification for added book’ feature.

Creating Our Interactor

Let’s create a folder for our interactors, and a folder for their specs:

We put them in lib/bookshelf because they’re decoupled from the web application. Later, you may want to add books via an admin portal, an API, or even a command-line utility.

Let’s call our interactor AddBook, and write a new spec spec/bookshelf/interactors/add_book_spec.rb:

  1. # spec/bookshelf/interactors/add_book_spec.rb
  2. RSpec.describe AddBook do
  3. let(:interactor) { AddBook.new }
  4. let(:attributes) { Hash.new }
  5. it "succeeds" do
  6. result = interactor.call(attributes)
  7. expect(result.successful?).to be(true)
  8. end
  9. end

Running your test suite will cause a NameError because there is no AddBook class. Let’s create that class in a lib/bookshelf/interactors/add_book.rb file:

  1. require 'hanami/interactor'
  2. class AddBook
  3. include Hanami::Interactor
  4. def initialize
  5. # set up the object
  6. end
  7. def call(book_attributes)
  8. # get it done
  9. end
  10. end

These are the only two public methods this class should ever have: #initialize, to set-up the data, and #call to actually fulfill the use-case.

These methods, especially #call, should call private methods that you’ll write.

By default, the result is considered a success, since we didn’t say that it explicitly say it failed.

Let’s run this test:

  1. $ bundle exec rake

All the tests should pass!

Creating a Book

Edit spec/bookshelf/interactors/add_book_spec.rb:

  1. # spec/bookshelf/interactors/add_book_spec.rb
  2. RSpec.describe AddBook do
  3. let(:interactor) { AddBook.new }
  4. let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
  5. context "good input" do
  6. let(:result) { interactor.call(attributes) }
  7. it "succeeds" do
  8. expect(result.successful?).to be(true)
  9. end
  10. it "creates a Book with correct title and author" do
  11. expect(result.book.title).to eq("The Fire Next Time")
  12. expect(result.book.author).to eq("James Baldwin")
  13. end
  14. end
  15. end

If you run the tests with bundle exec rake, you’ll see this error:

  1. NoMethodError: undefined method `book' for #<Hanami::Interactor::Result:0x007f94498c1718>

Let’s fill out our interactor, then explain what we did:

There are two important things to note here:

The expose :book line exposes the @book instance variable as a method on the result that will be returned.

The call method assigns a new Book entity to the @book variable, which will be exposed to the result.

The tests should pass now.

We’ve initialized a new Book entity, but it’s not persisted to the database.

Persisting the Book

We have a new Book built from the title and author passed in, but it doesn’t exist in the database yet.

We need to use our BookRepository to persist it.

  1. # spec/bookshelf/interactors/add_book_spec.rb
  2. RSpec.describe AddBook do
  3. let(:interactor) { AddBook.new }
  4. let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
  5. context "good input" do
  6. let(:result) { interactor.call(attributes) }
  7. it "succeeds" do
  8. expect(result.successful?).to be(true)
  9. end
  10. it "creates a Book with correct title and author" do
  11. expect(result.book.author).to eq("James Baldwin")
  12. end
  13. it "persists the Book" do
  14. expect(result.book.id).to_not be_nil
  15. end
  16. end
  17. end

If you run the tests, you’ll see the new expectation fails with

This is because the book we built doesn’t have an id, since it only gets one if and when it is persisted.

To make this test pass, we’ll need to create a persisted Book instead. (Another, equally valid, option would be to persist the Book we already have.)

Edit the call method in our lib/bookshelf/interactors/add_book.rb interactor:

  1. def call(book_attributes)
  2. @book = BookRepository.new.create(book_attributes)
  3. end

Instead of calling Book.new, we create a new BookRepository and send create to it, with our attributes.

This still returns a Book, but it also persists this record to the database.

If you run the tests now you’ll see all the tests pass.

Let’s refactor our implementation though, to leverage Dependency Injection

We recommend you use Dependency Injection, but you don’t have to. This is an entirely optional feature of Hanami::Interactor.

The spec so far works, but it relies on the behavior of the Repository (that the id method is defined after persistence succeeds). That is an implementation detail of how the Repository works. For example, if you wanted to create a UUID before it’s persisted, and signify the persistence was successful in some other way than populating an id column, you’d have to modify this spec.

We can change our spec and our interactor to make it more robust: it’ll be less likely to break because of changes outside of its file.

Here’s how we can use Dependency Injection in our interactor:

  1. require 'hanami/interactor'
  2. class AddBook
  3. include Hanami::Interactor
  4. expose :book
  5. def initialize(repository: BookRepository.new)
  6. @repository = repository
  7. end
  8. def call(book_attributes)
  9. @book = @repository.create(book_attributes)
  10. end
  11. end

It’s basically the same thing, with a little bit more code, to create the @repository instance variable.

Right now, our spec tests the behavior of the repository, by checking to make sure id is populated (expect(result.book.id).to_not be(nil)).

This is an implementation detail.

Instead, we can change our spec to merely make sure the repository receives the create message, and trust that the repository will persist it (since that is its responsibility).

Let’s move our it "persists the Book" expectation inside of a context "persistence" block:

  1. # spec/bookshelf/interactors/add_book_spec.rb
  2. RSpec.describe AddBook do
  3. let(:interactor) { AddBook.new }
  4. let(:attributes) { Hash[author: "James Baldwin", title: "The Fire Next Time"] }
  5. context "good input" do
  6. let(:result) { interactor.call(attributes) }
  7. it "succeeds" do
  8. expect(result.successful?).to be(true)
  9. end
  10. it "creates a Book with correct title and author" do
  11. expect(result.book.title).to eq("The Fire Next Time")
  12. expect(result.book.author).to eq("James Baldwin")
  13. end
  14. end
  15. context "persistence" do
  16. let(:repository) { instance_double("BookRepository") }
  17. it "persists the Book" do
  18. expect(repository).to receive(:create)
  19. AddBook.new(repository: repository).call(attributes)
  20. end
  21. end
  22. end

Now our test doesn’t violate the boundaries of the concern.

What we did here is inject our interactor’s dependency on the repository. Note: in our non-test code, we don’t need to change anything. The default value for the repository: keyword argument provides a new repository object if one is not passed in.

Email Notification

Let’s add the email notification!

You can use a different library, but we’ll use Hanami::Mailer. (You could do anything here, like send an SMS, send a chat message, or call a webhook.)

  1. $ bundle exec hanami generate mailer book_added_notification
  2. create lib/bookshelf/mailers/book_added_notification.rb
  3. create spec/bookshelf/mailers/book_added_notification_spec.rb
  4. create lib/bookshelf/mailers/templates/book_added_notification.txt.erb
  5. create lib/bookshelf/mailers/templates/book_added_notification.html.erb

We won’t get into the details of how the mailer works, but it’s pretty simple: there’s a Hanami::Mailer class, an associated spec, and two templates (one for plaintext, and one for html).

We’ll keep our templates empty, so the emails will be blank, with a subject line saying ‘Book added!’.

Edit the mailer spec spec/bookshelf/mailers/book_added_notification_spec.rb:

  1. # lib/bookshelf/mailers/book_added_notification.rb
  2. class Mailers::BookAddedNotification
  3. include Hanami::Mailer
  4. from 'no-reply@example.com'
  5. to 'admin@example.com'
  6. subject 'Book added!'
  7. end

Now all our tests should pass!

But, this Mailer isn’t called from anywhere. We need to call this Mailer from our AddBook interactor.

Let’s edit our AddBook spec, to ensure our mailer is called:

  1. ...
  2. context "sending email" do
  3. let(:mailer) { instance_double("Mailers::BookAddedNotification") }
  4. expect(mailer).to receive(:deliver)
  5. AddBook.new(mailer: mailer).call(attributes)
  6. end
  7. ...

Running your test suite will show an error: ArgumentError: unknown keyword: mailer. This makes sense, since our interactor has only a singular keyword argument: repository.

Let’s integrate our mailer now, by adding a new mailer keyword argument on the initializer.

We’ll also call deliver on our new @mailer instance variable.

  1. require 'hanami/interactor'
  2. class AddBook
  3. include Hanami::Interactor
  4. expose :book
  5. def initialize(repository: BookRepository.new, mailer: Mailers::BookAddedNotification.new)
  6. @repository = repository
  7. @mailer = mailer
  8. end
  9. def call(book_attributes)
  10. @book = @repository.create(book_attributes)
  11. @mailer.deliver
  12. end
  13. end

Now our interactor will deliver an email, notifying that a book has been added.

Integrating With Our Controller

Finally, we need to call this interactor from our action.

Edit the action file, apps/web/controllers/books/create.rb:

  1. def call(params)
  2. if params.valid?
  3. @book = AddBook.new.call(params[:book])
  4. redirect_to routes.books_path
  5. else
  6. self.status = 422
  7. end
  8. end

Our specs will still pass, but there’s a small problem.

We’re testing the book creation code twice.

This is generally bad practice, and we can fix it, by illustrating another benefit of interactors.

We’re going to use Dependency Injection again. This time, in our action.

We’ll add an initialize method, with a keyword argument for interactor.

But first, let’s edit the spec spec/web/controllers/books/create_spec.rb.

We’re going to remove references to BookRepository, and leverage a double for our AddBook interactor:

  1. # spec/web/controllers/books/create_spec.rb
  2. RSpec.describe Web::Controllers::Books::Create do
  3. let(:interactor) { instance_double('AddBook', call: nil) }
  4. let(:action) { described_class.new(interactor: interactor) }
  5. context 'with valid params' do
  6. let(:params) { Hash[book: { title: '1984', author: 'George Orwell' }] }
  7. it 'calls interactor' do
  8. expect(interactor).to receive(:call)
  9. action.call(params)
  10. end
  11. it 'redirects the user to the books listing' do
  12. response = action.call(params)
  13. expect(response[0]).to eq(302)
  14. expect(response[1]['Location']).to eq('/books')
  15. end
  16. end
  17. context 'with invalid params' do
  18. let(:params) { Hash[book: {}] }
  19. it 'does not call interactor' do
  20. expect(interactor).to_not receive(:call)
  21. action.call(params)
  22. end
  23. it 're-renders the books#new view' do
  24. response = action.call(params)
  25. expect(response[0]).to eq(422)
  26. end
  27. it 'sets errors attribute accordingly' do
  28. action.call(params)
  29. expect(action.params.errors[:book][:title]).to eq(['is missing'])
  30. expect(action.params.errors[:book][:author]).to eq(['is missing'])
  31. end
  32. end

The test will cause an error, because we haven’t overridden our initialize. Let’s do that now, and leverage our new instance variable in the #call method:

Now our specs pass, and they’re much more robust!

Our action now has less responsibility; it delegates its real behavior to our interactor.

The action takes input (from parameters), and calls our interactor to actually do its work. Its single responsibility is to deal with the web. Our interactor now deals with our actual business logic.

This is a great relief for our action and its spec.

Our action is largely liberated from our business logic.

When we modify our interactor, we do not have to modify our action, or its spec.

(Note that in a real app, you’ll likely want to do more than our logic above, like make sure the result is a success. Else, if it’s a failure, you’ll want to pass along errors from the interactor.)

Interactor parts

The interface is rather simple, as shown above. There’s also one more method you can (optionally) implement. It’s a private method named #valid?.

By default #valid? returns true. If you define #valid? and it ever returns false, then #call will never be executed.

Instead, the result will be returned immediately. This also causes the result to be a failure (instead of a success.)

You can read about it in the API documentation

Result

The result of Hanami::Interactor#call is a Hanami::Interactor::Result object.

It will have accessor methods defined for whatever instance variables you expose.

It also has the ability to keep track of errors.

In your interactor, you can call error with a message, to add an error. This automatically makes the resulting object a failure.

(There’s also an error! method, which does the same and also interrupts the flow, stopping the interactor from executing more code).

You can access the errors on the resulting object, by calling .