Manage Rails app business logic using ActiveInteraction gem
While working with Rails applications, developers are concerned about adding business logic in the right place. Developers have to ensure that the business logic is well understood, maintainable and readable as the application grows.
To abstract and encapsulate the application business logic, developers use service classes or service objects, presenters, decorators, etc. But we have a few gems that make our life easier in maintaining our code.
Let us explore the ActiveInteraction gem.
ActiveInteraction
As per the GitHub repo,
ActiveInteraction gives you a place to put your business logic.
It also helps you write safer code by validating that your inputs conform to your expectations.
Installation
To use this gem,
we need to add the following line to the Gemfile
and
execute the bundle install command.
gem 'active_interaction', '~> 5.1'
Basic Usage: Multiply two numbers
Let's say we want to multiply two numbers that handle the basic validations
and
return the result.
We create a class MultiplyTwoNumbers
which gets inherited from ActiveInteraction::Base
.
class MultiplyTwoNumbers < ActiveInteraction::Base
integer :a
integer :b
def execute
a * b
end
end
We defined our inputs a and b of integer type, which are the arguments that will be passed when calling this interaction. We added an execute method that returns the multiplication of a and b.
We can call the .run
or .run!
method on
MultiplyTwoNumbers
to execute it.
The former will not raise any exception in case of any errors,
but the errors can be accessed using the .errors.messages
method.
The latter will raise an exception in case of any error.
# .run method
outcome = MultiplyTwoNumbers.run(a: 1, b: "two")
outcome.valid?
# => false
outcome.errors.messages
# => {:b=>["is not a valid integer"]}
outcome = MultiplyTwoNumbers.run(a: 1, b: 2)
outcome.valid?
# => true
outcome.result
# => 2
# .run! method
MultiplyTwoNumbers.run!(a: 1, b: "two")
ActiveInteraction::InvalidInteractionError: B is not a valid integer
MultiplyTwoNumbers.run!(a: 1, b: 2)
# => 2
-
We called the
.run
method and passed argument b as a string instead of an integer. -
We assigned the result to an outcome variable and used the
.valid?
to verify if there were any errors. In this case,.valid?
returns false. -
To access the error messages, we can use
.errors.messages
. -
When we pass valid arguments the
.valid?
method returns true. -
We can fetch the output by using the
.result
method. -
When we call the
.run!
method and pass invalid arguments to it, it raises an ActiveInteraction::InvalidInteractionError and displays the error message. -
If we pass valid arguments
.run!
method directly returns the result.
Note: ActiveInteraction does not provide its validations. They are from ActiveModel. We can hence add custom validations in our interactions.
class MultiplyTwoNumbers < ActiveInteraction::Base
integer :a
integer :b
validates :a, presence: true
def execute
a * b
end
end
MultiplyTwoNumbers.run!(a: nil, b: 2)
ActiveInteraction::InvalidInteractionError: A is required
ActiveInteraction first checks for the type of input. After this check passes, ActiveModel validations come into the picture. When both these checks are green, execute method gets called.
Using ActiveInteraction in Rails
ActiveInteraction gels well with Rails.
As per the gem, creating an /interactions
directory is recommended under Rails /app
folders.
As per the above example, MultiplyTwoNumbers is a single-purpose interaction. In real-life scenarios, we might have to deal with multiple interactions.
Let us imagine a scenario where a Rails application is supposed to import employee data from different companies and convert it into a standard format.
We might have to break the task into three steps:
-
Fetch employee data from the company portal.
-
Convert the employee data into a standard format, which our system can import.
-
Import the standard format into our system.
Each step can be its own interaction class, and we create a parent interaction class that calls these three interactions in sequence.
# ImportEmployeesData: Parent Interaction
class ImportEmployeesData < ActiveInteraction::Base
object :client,
desc: "Third-party service from where we will fetch employees' data"
def execute
employees_data = compose(
FetchEmployeesData,
client: client
)
standard_data = compose(
ConvertEmployeesDataIntoStandardFormat,
data: employees_data
)
compose(
ImportEmployees,
data: standard_data
)
end
end
# FetchEmployeesData
class FetchEmployeesData < ActiveInteraction::Base
object :client,
desc: "Third-party service from where we will fetch employees' data"
def execute
# API call to the third-party service
client.fetch_employees
end
end
# ConvertEmployeesDataIntoStandardFormat
class ConvertEmployeesDataIntoStandardFormat < ActiveInteraction::Base
object :data,
desc: "Employee data that will be converted into the standard format"
def execute
# code that converts the employee data into the standard format
end
end
# ImportEmployees
class ImportEmployees < ActiveInteraction::Base
object :data,
desc: "Standard data that will be imported into our system"
def execute
# code to import employees into our database
end
end
As seen in the above example,
we can call interactions within other interactions using the #compose
method.
If anyone of the interactions fails,
an exception gets raised like the exception raised in
.run!
and
the execution stops.
If all the interactions get executed successfully,
we return the result directly.
Note: We have a client object that refers to the third-party service. For example, let's assume a company employee's data exists on the 15Five portal. We fetch their data using 15Five APIs and create a class as below.
class FifteenFive
def initialize
# client id and client secret
end
def fetch_employees
# API to fetch employees' data
end
end
We can similarly create classes for different clients we have in our system. Finally, to import the employees, we can call the parent interaction as shown below:
ImportEmployeesData.run!(client: FifteenFive.new)
The advantage of using this gem is as follows:
-
We don’t have to write code for validating the inputs. The gem is taking care of it.
-
We can break down complex and huge business logic into smaller interactions.
-
These smaller interactions can help us achieve the Single Responsibility Principle.
-
Writing test cases for these interactions is much easier and faster.
-
The smaller interactions can be reused at multiple places making our code DRY.
-
Adding a new feature to the existing business logic becomes easier
-
Finally, our controllers and models are skinny.
A few disadvantages or issues when using this gem are as follows:
-
Sometimes, the model logic gets added in the interaction file we create, breaking the Rails coding convention. For e.g., when working with state machine AASM gem, if we choose to follow the ActiveInteraction pattern, we might have to shift our model AASM into its file.
class Job include AASM aasm do state :sleeping, initial: true, before_enter: :do_something state :running, before_enter: Proc.new { do_something && notify_somebody } state :finished after_all_transitions :log_status_change event :run, after: :notify_somebody do before do log('Preparing to run') end transitions from: :sleeping, to: :running, after: Proc.new {|*args| set_process(*args) } transitions from: :running, to: :finished, after: LogRunTime end end
Using the interaction gem
class JobStateChange < ActiveInteraction::Base object :job def execute return if job.finished? job.log job.running! job.notify_somebody end end
-
The ActiveInteraction gem has support for
Formtastic
andsimple_form
. But, unlike Rails form validations, error highlighting is different in the case of ActiveInteraction gem. To know more about the issue, please refer to this link. -
The gem has support for basic filters like Hash, Array and Interface. But support for the complex data structure is missing. Check this link to know more about the issue.
To know more about this gem and its features, check out this GitHub Repo.