Interactify
Interactify enhances Rails applications by simplifying complex interactor chains.
This gem builds on interactors and interactor-contracts to improve readability and maintainability of business logic.
We depend on activesupport, and optionally on railties and sidekiq. So it's a good fit for Rails projects using Sidekiq, offering advanced features for chain management and debugging.
Interactify is about making interactor usage in Rails more efficient and less error-prone, reducing the overhead of traditional interactor orchestration.
Installation
gem 'interactify'
Usage
Initializer
# in config/initializers/interactify.rb
Interactify.configure do |config|
# defaults
config.root = Rails.root / 'app'
config.on_contract_breach do |context, contract_failures|
# maybe add context to Sentry or Honeybadger etc here
end
# called when an Interactify.organizing or Interactify.promising fails to match the actual interactor
# definitions
config.on_definition_error = Kernel.method(:raise)
# config.on_definition_error do |error|
# # you may want to raise an error in test but not in production
# # or you may want to log the error
# end
config.before_raise do |exception|
# maybe add context to Sentry or Honeybadger etc here
end
end
Using the RSpec matchers
# e.g. in spec/support/interactify.rb
require 'interactify/rspec_matchers/matchers'
# in specs
expect(described_class).to expect_inputs(:foo, :bar, :baz)
expect(described_class).to promise_outputs(:fee, :fi, :fo, :fum)
Syntactic Sugar
- Everything is an Organizer/Interactor and supports interactor-contracts.
- They only becomes considered an organizer once
organize
is called. - They could technically be both (if you want?) but you have to remember to call
super
withincall
to trigger the organized interactors. - Concise syntax for most common scenarios with
expects
andpromises
. Verifying the presence of the keys/values. - Automatic delegation of expected and promised keys to the context.
# before
class LoadOrder
include Interactor
include Interactor::Contracts
expects do
required(:id).filled
required(:something_else).filled
required(:a_boolean_flag)
end
promises do
required(:order).filled
end
def call
context.order = Order.find(context.id)
end
end
# after
class LoadOrder
include Interactify
expect :id, :something_else
expect :a_boolean_flag, filled: false
promise :order
def call
context.order = Order.find(id)
end
end
filled: false
Both expect and promise can take the optional parameter filled: false
This means that whilst the key is expected to be passed, it doesn't have to have a truthy or present value.
Use this where valid values include, []
, ""
, nil
or false
etc.
optional
class LoadOrder
include Interactify
optional :some_key, :another_key
end
Optional can be used to denote that the key is not required to be passed.
This is effectively equivalent to delegate :key, to: :context
, but does not require the key to be present in the context.
This is not recommended as the keys will not be validated by the contract or the interactor wiring specs.
Lambdas
With vanilla interactors, it wasn't possible to use lambdas in organizers. But sometimes we only want a lambda. So we added support.
organize LoadOrder, ->(context) { context.order = context.order.decorate }
organize \
Thing1,
->(c){ byebug if c.order.nil? },
Thing2
Note
organized
method calls that use this organizer will blow up when adding temporary lambdas for debugging.
For now you can add custom error handling, using
Interactify.on_definition_error { |error| # do something other than raise }
I'm considering removing plain lambdas that have not been assigned to a constant from the organized
checks.
It wouldn't be possible to refer to a lambda within an organized
call anyway. I'm open to suggestions here.
More testable one line lambdas
Sometimes you also want a one liner but testability too.
the Interactify
method will take a block or a lambda and return an Interactify class.
# passing a block
DecorateOrder = Interactify do |context|
context.order = context.order.decorate
end
# passing a lambda
DecorateOrder = Interactify(some_lambda)
# passing anything that responds to call
# please note if you pass a class it will be instantiated first
# so you can't pass a class with a constructor that takes arguments
DecorateOrder = Interactify(callable_object)
Each/Iteration
Sometimes we want an interactor for each item in a collection. But it gets unwieldy. It was complex procedural code and is now broken into neat SRP classes. But there is still boilerplate and jumping around between files to follow the orchestration. It's easy to get lost in the orchestration code that occurs across say 7 or 8 files.
So the complexity problem is just moved to the gaps between the classes and files.
We gain things like EachOrder
, or EachProduct
interactors.
Less obvious, still there.
By using Interactify.each
we can keep the orchestration code in one place.
We get slightly more complex organizers, but a simpler mental model of organizer as orchestrator and SRP interactors.
# before
class OuterOrganizer
# ... boilerplate ...
organize SetupStep, LoadOrders, DoSomethingWithOrders
end
class LoadOrders
# ... boilerplate ...
def call
context.orders = context.ids.map do |id|
LoadOrder.call(id: id).order
end
end
end
class LoadOrder
# ... boilerplate ...
def call
# ...
end
end
class DoSomethingWithOrders
# ... boilerplate ...
def call
context.orders.each do |order|
DoSomethingWithOrder.call(order: order)
end
end
end
class DoSomethingWithOrder
# ... boilerplate ...
def call
# ...
end
end
# after
class OuterOrganizer
# ... boilerplate ...
organize \
SetupStep,
self.each(:ids,
LoadOrder,
->(c){ byebug if c.order.nil? },
DoSomethingWithOrder
)
end
class LoadOrder
# ... boilerplate ...
def call
# ...
end
end
class DoSomethingWithOrder
# ... boilerplate ...
def call
# ...
end
end
Conditionals (if/else) with lambda
Along the same lines of each/iteration. We sometimes have to 'break the chain' with interactors just to conditionally call one interactor chain path or another.
The same mental model problem applies. We have to jump around between files to follow the orchestration.
# before
class OuterThing
# ... boilerplate ...
organize SetupStep, InnerThing
end
class InnerThing
# ... boilerplate ...
def call
if context.thing == 'a'
DoThingA.call(context)
else
DoThingB.call(context)
end
end
end
# after
class OuterThing
# ... boilerplate ...
organize \
SetupStep,
# lambda conditional
self.if(->(c){ c.thing == 'a' }, DoThingA, DoThingB),
# context conditional
self.if(:some_key_on_context, DoThingA, DoThingB),
# alternative hash syntax
{if: :key_set_on_context, then: DoThingA, else: DoThingB},
# method call with hash syntax, plus implicit chaining
self.if(:key_set_on_context, then: [A, B, C], else: [B, C, D]),
# method call with lambda, hash syntax, and implicit chaining
self.if(->(ctx) { ctx.this }, then: [A, B, C], else: [B, C, D]),
AfterDoThis
end
Simple chains
Sometimes you want an organizer that just calls a few interactors in a row. You may want to create these dynamically at load time, or you may just want to keep the orchestration in one place.
self.chain
is a simple way to do this.
class SomeOrganizer
include Interactify
organize \
self.if(:key_set_on_context, self.chain(DoThingA, ThenB, ThenC), DoDifferentThingB),
EitherWayDoThis
end
Contract validation failures
Sometimes contract validation fails at runtime as an exception. It's something unexpected and you'll have an Interactor::Failure
sent to rollbar/sentry/honeybadger.
If the context is large it's often hard to spot what the actual problem is or where it occurred.
before
Interactor::Failure
#<Interactor::Context output_destination="DataExportSystem", output_format=:xml, region_code="XX", custom_flag=false, process_mode="sample", cache_identifier="GenericProcessorSample-XML-XX-0", data_key="GenericProcessorSample", data_version=0, last_process_time=2023-12-26 04:00:18.953000000 GMT +00:00, process_start_time=2023-12-26 06:45:17.915237484 UTC, updated_ids=[BSON::ObjectId('123f77a58444201ff1f0611a'), BSON::ObjectId('123f78148444201fd62a2e9b'), BSON::ObjectId('12375d8084442038712ba40e')], lock_info=#<Processing::Lock _id: 123a767d7b944674cc069064, created_at: 2023-12-26 06:45:17.992417809 UTC, updated_at: 2023-12-26 06:45:17.992417809 UTC, processor: "DataExportSystem", format: "xml", type: "sample">, expired_cache_ids=[], jobs=['jobs must be filled'] items=#<Mongoid::Criteria (Interactor::Failure)
, tasks=[]>
after with call
#<Interactor::Context output_destination="DataExportSystem", output_format=:xml, region_code="XX", custom_flag=false, process_mode="sample", cache_identifier="GenericProcessorSample-XML-XX-0", data_key="GenericProcessorSample", data_version=0, last_process_time=2023-12-26 04:00:18.953000000 GMT +00:00, process_start_time=2023-12-26 06:45:17.915237484 UTC, updated_ids=[BSON::ObjectId('123f77a58444201ff1f0611a'), BSON::ObjectId('123f78148444201fd62a2e9b'), BSON::ObjectId('12375d8084442038712ba40e')], lock_info=#<Processing::Lock _id: 123a767d7b944674cc069064, created_at: 2023-12-26 06:45:17.992417809 UTC, updated_at: 2023-12-26 06:45:17.992417809 UTC, processor: "DataExportSystem", format: "xml", type: "sample">, expired_cache_ids=[], tasks=['tasks must be filled'] items=#<Mongoid::Criteria (Interactor::Failure)
, tasks=[], contract_failures={:tasks=>["tasks must be filled"]}>
after with call!
#<SomeSpecificInteractor::ContractFailure output_destination="DataExportSystem", output_format=:xml, region_code="XX", custom_flag=false, process_mode="sample", cache_identifier="GenericProcessorSample-XML-XX-0", data_key="GenericProcessorSample", data_version=0, last_process_time=2023-12-26 04:00:18.953000000 GMT +00:00, process_start_time=2023-12-26 06:45:17.915237484 UTC, updated_ids=[BSON::ObjectId('123f77a58444201ff1f0611a'), BSON::ObjectId('123f78148444201fd62a2e9b'), BSON::ObjectId('12375d8084442038712ba40e')], lock_info=#<Processing::Lock _id: 123a767d7b944674cc069064, created_at: 2023-12-26 06:45:17.992417809 UTC, updated_at: 2023-12-26 06:45:17.992417809 UTC, processor: "DataExportSystem", format: "xml", type: "sample">, expired_cache_ids=[], tasks=['tasks must be filled'] items=#<Mongoid::Criteria (Interactor::Failure)
, tasks=[], contract_failures={:tasks=>["tasks must be filled"]}>
Promising
You can annotate your interactors in the organize arguments with their promises. This then acts as executable documentation that is validated at load time and enforced to stay in sync with the interactor.
A writer of an organizer may quite reasonably expect LoadOrder
to promise :order
, but for the reader, it's not always as immediately obvious
which interactor in the chain is responsible for provides which key.
organize \
LoadOrder.promising(:order),
TakePayment.promising(:payment_transaction)
This will be validated at load time against the interactors promises. An example of a failure would be:
SomeOrganizer::DoStep1 does not promise:
step_1
Actual promises are:
step1
Organizing
You can now annotate your interactors in the organize arguments with their sub-organizers' interactors. This also serves as executable documentation, validated at load time, and is enforced to stay in sync.
organize \
LoadOrder,
MarkAsPaid,
SendOutNotifications.organizing(
EmailUser,
SendPush,
NotifySomeThirdParty,
SendOutNotifications::DoAnotherThing
)
class SendOutNotifications
organize \
EmailUser,
SendPush,
NotifySomeThirdParty,
SetData = Interactify do |context|
context.data = {this: true}
end
end
In this example, it might seem reasonable for an editor of SendOutNotifications to append SetData to the end of the chain. However, the naming here is not ideal. If it's generically named and not easily searchable, then the discoverability of the code is reduced.
By invoking .organizing
when the original author first uses SendOutNotifications
, it encourages the subsequent editor to think about and document its own callers.
They may still choose to add SetData
to the end of the chain, but this increases the chances of more easily finding where the data is changed.
Example load time failure:
SendOutNotifications does not organize:
[EmailUser, SendPush, NotifySomeThirdParty]
Actual organized classes are:
[EmailUser, SendPush, NotifySomeThirdParty, SendOutNotifications::SetData]
Expected organized classes are:
[SendOutNotifications::SetData]
Interactor wiring specs
Sometimes you have an interactor chain that fails because something is expected deeper down the chain and not provided further up the chain.
The existing way to solve this is with enough integration specs to catch them, hunting and sticking a byebug
, debugger
or binding.pry
in at suspected locations and inferring where in the chain the wiring went awry.
But we can do better than that if we always promise
something that is later expect
ed.
In order to detect these wiring issues, stick a spec in your test suite like this:
RSpec.describe 'InteractorWiring' do
it 'validates the interactors in the whole app', :aggregate_failures do
errors = Interactify.validate_app(ignore: [/SomeClassName/, AnotherClass, 'SomeClassNameString'])
expect(errors).to eq ''
end
end
Missing keys: :order_id
in: AssignOrderToUser
for: PlaceOrder
This allows you to quickly see exactly where you missed assigning something to the context.
Combine with lambda debugging ->(ctx) { byebug if ctx.order_id.nil?},
in your chains to drop into the exact
location in the chain to find where to make the change.
RSpec matchers
Easily add low value, low cost specs for your expects and promises.
expect(described_class).to expect_inputs(:order_id)
expect(described_class).to promise_outputs(:order)
Sidekiq Jobs
Sometimes you want to asyncify an interactor.
before
- SomeInteractor.call(*args)
+ class SomeInteractorJob
+ include Sidekiq::Job
+
+ def perform(*args)
+ SomeInteractor.call(*args)
+ end
+ end
+
+ SomeInteractorJob.perform_async(*args)
after
- SomeInteractor.call!(*args)
+ SomeInteractor::Async.call!(*args)
No need to manually create a job class or handle the perform/call impedance mismatch
This also makes it easy to add cron jobs to run interactors. As any interactor can be asyncified. By using it's internal Async class.
Caution
As your class is now executing asynchronously you can no longer rely on its promises later on in the chain.
Sidekiq options
class SomeInteractor
include Interactify.with(queue: 'within_30_seconds')
end
This allows you to set the sidekiq options for the asyncified interactor. It will autogenerate a class name that has the options set.
SomeInteractor::Job__Queue_Within30Seconds
or with a random number suffix
if there is a naming clash.
SomeInteractor::Job__Queue_Within30Seconds_5342
This is also aliased as SomeInteractor::Job
for convenience.
An almost equivalent to the above without the .with
method is:
class SomeInteractor
include Interactify
class JobWithin30Seconds < Job
sidekiq_options queue: 'within_30_seconds'
end
end
Here the JobWithin30Seconds class is manually set up and subclasses the one
automatically created by include Interactify
.
FAQs
- This is ugly isn't it?
class OuterOrganizer
# ... boilerplate ...
organize \
SetupStep,
self.each(:ids,
LoadOrder,
->(c){ byebug if c.order.nil? },
DoSomethingWithOrder
)
end
- Do you find the syntax of OuterOrganizer ugly?
While the syntax might seem unconventional initially, its conceptual elegance lies in streamlining complex interactor chains. Traditional methods often involve navigating through multiple files, creating a hidden and cumbersome architecture. This gem aims to alleviate that by centralizing operations, making the overall process more intuitive.
- Is this compatible with interactor/interactor-contracts?
Yes, it's fully compatible. We currently use these as dependencies. While there's a possibility of future changes, maintaining this compatibility is a priority.
- Why not suggest enhancements to the interactor or interactor-contracts gems?
These gems are excellent in their own right, which is why we've built upon them. Proposing such extensive changes might not align with their current philosophy. However, if our approach proves stable and garners interest, we're open to discussing potential contributions to these gems.
- Is this just syntactic sugar?
It's more than that. This approach enhances readability and comprehension of the code. It simplifies the structure, making it easier to navigate and maintain.
- Is the new DSL/syntax easier to understand than plain old Ruby objects (POROs)?
This is subjective, but we believe it is. It reduces the need for numerous files addressing common interactor issues, thereby streamlining the workflow.
- Doesn't this approach become verbose and complex in large applications?
While it may appear so, this method shines in large-scale applications with numerous interactors. It simplifies locating and modifying the necessary interactors, which is often a cumbersome process.
- What if I prefer using Service Objects?
That's completely valid. Service Objects have their merits, but this gem is particularly useful for those deeply engaged with interactors. It capitalizes on the chaining, contracts, simplicity, composability, and testability that interactors offer. Combining Service Objects with interactors might not retain these advantages as effectively.
License
The gem is available as open source under the terms of the MIT License.