interactors-good-bad-ugly



interactors-good-bad-ugly

0 0


interactors-good-bad-ugly

My presentation on Interactors for WMRUG

On Github LimeBlast / interactors-good-bad-ugly

Interactors

The Good, The Bad, and The Ugly!

(or, why did I agree to this?)

What is an interactor?

  • Single purpose object
  • Encapsulates business logic
  • Known as Commands in other languages

What's in a name?

  • CancelAccount, not DestroyUser
  • PublishArticle, not CreatePost
Interactors are named after business logic, not implementation. This allows you to get a general understanding of what a application does by glancing at it's interactors.

Interactor Gems

  • orgsync/active_interaction
  • collectiveidea/interactor
Over the past couple of months I've tried a couple of different interactor gems, here are my thoughts on them.

ActiveInteraction

https://github.com/orgsync/active_interaction

ActiveInteraction is maintained by OrgSync, and is currently on version 2.1.2

Be all and end all

  • Inputs
  • Validation
  • Logic
ActiveInteractor is taking the be all and end all approach to the problem by creating it's own DSL which lets you define everything in one place, including all the inputs, validation and logic required to perform the action.

Inputs

class SayHello < ActiveInteraction::Base
  string :name

  interface :serializer,
    methods: %i[dump load]

  object :cow # Cow class

  #...
end
You start by defining your inputs, of which there are a large number of filters to choose from, including all the ones you'd expect such Strings, Arrays, Booleans, etc.. as well as the ability to define an inputs that have specific interfaces, or to be an instance of a particular class.

Inputs cont.

class SayHello < ActiveInteraction::Base
  string :name,
    default: 'World',
    desc: "Who you're saying hello to"

  #...
end
And optionally, you've got the ability to set a default for each input, as well as a description which can be used to generate documentation (although I've not looked at this aspect of it)

Validation

class SayHello < ActiveInteraction::Base
  string :name

  validates :name,
    presence: true

  #...
end
Next, if you want some more control over the data which gets input, you've got the ability to set some able to use ActiveModel validations. These are entirely optional.

Logic

class SayHello < ActiveInteraction::Base
  string :name

  validates :name,
    presence: true

  def execute
    "Hello, #{name}!" # or: "Hello, #{inputs[:name]}!"
  end
end
Then finally you perform the logic that's required inside an execute method. All the inputs defined are available as local variables, or as a hash named inputs.

Errors

class FindAccount < ActiveInteraction::Base
  integer :id

  def execute
    account = Account.not_deleted.find_by_id(id)

    if account
      account
    else
      errors.add(:id, 'does not exist')
    end
  end
end
If your logic is able to perform successfully, you simply return the result of the operation, but if something goes wrong, you return an error object with a description of the problem.

Running the interaction

# GET /accounts/:id
def show
  @account = find_account!
end

private

def find_account!
  outcome = FindAccount.run(params)

  if outcome.valid?
    outcome.result
  else
    fail ActiveRecord::RecordNotFound, outcome.errors.full_messages.to_sentence
  end
end
Once you've built your interactors you're able to run them in one of two ways: .run and .run!. Both have you pass in your defined parameters as a hash, with the only different between the two being the outcome. .run returns an outcome object which you're able to test for success, and react accordingly...

Running the interaction!

SayHello.run!(name: nil)
# ActiveInteraction::InvalidInteractionError: Name is required

SayHello.run!(name: '')
# ActiveInteraction::InvalidInteractionError: Name can't be blank

SayHello.run!(name: 'Daniel')
# => "Hello, Daniel!"
and .run! does away with the outcome object, and either raises an exception on failure, or simply returns the value on success.

Forms

# GET /accounts/new
def new
  @account = CreateAccount.new
end

# POST /accounts
def create
  outcome = CreateAccount.run(params.fetch(:account, {}))

  if outcome.valid?
    redirect_to(outcome.result)
  else
    @account = outcome
    render(:new)
  end
end
ActiveInteraction plays nicely with rail's form_for (and by extension, simple_form and Formtastic) as the outcome object returned from .new or .run quacks like an ActiveModel form object.

form_for

<%= form_for @account, as: :account, url: accounts_path do |f| %>
  <%= f.text_field :first_name %>
  <%= f.text_field :last_name %>
  <%= f.submit 'Create' %>
<% end %>
Allowing for the form markup you're already used to.

Why I don't like ActiveInteraction

  • Painful to test
  • Ask, don't tell
So that all sounds pretty good, right? Except there are a couple of things I dislike. The first is that they're very painful to test because they violate the single responsible principle by simply doing so much (validations, logic, etc..). The second thing, which is more of a nitpick, is they're not following the principle of tell, don't ask.

interactor

https://github.com/collectiveidea/interactor

Interactor is maintained by Collective Idea, and is currently on version 3.1.0

Context

class SayHello
  include Interactor

  def call
    context.hello = "Hello, #{context.name}!"
  end
end

result = SayHello.call({name: 'Daniel'})
result.hello
# => "Hello, Daniel!"
The most important aspect of Interactor is the context, which gets created from the values you pass in, and manipulated accordingly by the business logic contained within. Unlike active_interactor, you don't define specific inputs, so if you have specific input or validation requirements, you'll need to establish them elsewhere.

Failing the Context

class AuthenticateUser
  include Interactor

  def call
    if user = User.authenticate(context.email, context.password)
      context.user = user
      context.token = user.secret_token
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end
When something goes wrong in your interactor, you can flag the context as failed....

Failing the Context cont.

class SessionsController < ApplicationController
  def create
    result = AuthenticateUser.call(session_params)

    if result.success?
      session[:user_token] = result.token
      redirect_to root_path
    else
      flash.now[:message] = t(result.message)
      render :new
    end
  end
end
Which will automatically trigger the result object to have .success? and .failure? booleans accordingly.

They do one thing

One of the things I like about Interactor over active_interaction is that each one is a PORO which does one thing, and one thing only. This makes them very easy to test as you're very easily able to stub and mock the context within a test....

Organizers

class PlaceOrder
  include Interactor::Organizer

  organize CreateOrder, ChargeCard, SendThankYou
end
And thanks to organisers, and the shared context which gets passed between them in the order defined, you're able to specify entire chains of events.

Rollback

class CreateOrder
  include Interactor

  def call
    order = Order.create(order_params)

    if order.persisted?
      context.order = order
    else
      context.fail!
    end
  end

  def rollback
    context.order.destroy
  end
end
If any one of the organized interactors fails its context, the organizer stops, with any interactors that had already run are given the chance to undo themselves, in reverse order, via a defined rollback method.

Why I don't like Interactor

  • Feels very loose
  • Passing around a hash, not objects
  • Ask, don't tell
While I like the idea of the interactor gem in theory, something about it feels wrong. It feels a bit loose and unstable, with different contexts getting in the way of one another the larger the organiser chain is. I'm also not entirely sure that it's actully object-oriented, as all you're doing it passing a generic context around. Also, nitpicky, but this is another Ask, don't tell solution.

Conclusion

  • Very different to each other
  • I don't like them
  • Rolling my own using Wisper and Virtus gems
  • I'm waiting for Ruby Object Mapper
So in conclusion, they're very different to each other, in the both approach they take and how they work, and I don't like either of them, but for different reasons. For my own part, I'm trying to build a system which uses a combination of Wisper and Virtus gems to provide a Tell, don't ask interface, and waiting for Ruby Object Mapper to mature.
Interactors The Good, The Bad, and The Ugly! (or, why did I agree to this?)