A New Feature: Email Notifications

The story for our new feature is:
> As an administrator, I want to receive an email notification when a book is added

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.

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.

Preparing

Let’s say we have our bookshelf application,
from the Getting Started
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:

$ mkdir lib/bookshelf/interactors
$ mkdir spec/bookshelf/interactors

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.

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

Let’s run this test:

$ bundle exec rake

All the tests should pass!

Now, let’s make our AddBook interactor actually do something!

Creating a Book

Edit spec/bookshelf/interactors/add_book_spec.rb:

# spec/bookshelf/interactors/add_book_spec.rbRSpec.describeAddBookdolet(:interactor){AddBook.new}let(:attributes){Hash[author:"James Baldwin",title:"The Fire Next Time"]}context"good input"dolet(:result){interactor.call(attributes)}it"succeeds"doexpect(result.successful?).tobe(true)endit"creates a Book with correct title and author"doexpect(result.book.title).toeq("The Fire Next Time")expect(result.book.author).toeq("James Baldwin")endendend

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

NoMethodError:undefinedmethod`book' for #<Hanami::Interactor::Result:0x007f94498c1718>

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

require'hanami/interactor'classAddBookincludeHanami::Interactorexpose:bookdefinitialize# set up the objectenddefcall(book_attributes)@book=Book.new(book_attributes)endend

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.

# spec/bookshelf/interactors/add_book_spec.rbRSpec.describeAddBookdolet(:interactor){AddBook.new}let(:attributes){Hash[author:"James Baldwin",title:"The Fire Next Time"]}context"good input"dolet(:result){interactor.call(attributes)}it"succeeds"doexpect(result.successful?).tobe(true)endit"creates a Book with correct title and author"doexpect(result.book.title).toeq("The Fire Next Time")expect(result.book.author).toeq("James Baldwin")endit"persists the Book"doexpect(result.book.id).to_notbe_nilendendend

If you run the tests,
you’ll see the new expectation fails with Expected nil to not be nil.

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 persistedBook instead.
(Another, equally valid, option would be to persist the Book we already have.)

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.

# spec/bookshelf/interactors/add_book_spec.rbRSpec.describeAddBookdolet(:interactor){AddBook.new}let(:attributes){Hash[author:"James Baldwin",title:"The Fire Next Time"]}context"good input"dolet(:result){interactor.call(attributes)}it"succeeds"doexpect(result.successful?).tobe(true)endit"creates a Book with correct title and author"doexpect(result.book.title).toeq("The Fire Next Time")expect(result.book.author).toeq("James Baldwin")endendcontext"persistence"dolet(:repository){instance_double("BookRepository")}it"persists the Book"doexpect(repository).toreceive(:create)AddBook.new(repository:repository).call(attributes)endendend

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.)

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:

...context"sending email"dolet(:mailer){instance_double("Mailers::BookAddedNotification")}it"send :deliver to the mailer"doexpect(mailer).toreceive(:deliver)AddBook.new(mailer:mailer).call(attributes)endend...

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.