Ni
The Developers Who Say Ni [nee]
- What it the easiest way to build a service with ruby?
- Ni
- What it the most convinient way to implement any business logic flow?
- Ni
- How can I stop care about the flow control and focus on things are really matter?
- Ni
- How can I build an ERP, CRM, BPM and other three letter things?
- Ni + Peng + NeeeWom => Graal
Warning
Because of my strong intentions to try these ideas in the real world projects ASAP sometimes I didn't have enough time for solid solutions or decent tests. So, please use only DSL described below. If it doesn't allows you to solve your issues, do not use interactor then
All things described below are possible because of the ruby power. A lot metaprogrammings and high complexity code under the hood Ni to implement whatever flow you need. But, in some cases I've add additional stricts, which should not allow you to make a bad choices. They are described as Strict! Bold text ...
Features
The most simple interactor
class Interactor
include Ni::Main
receive :param_1
def perform
self.context.errors.add(:base, 'An error') if context.param_1 == '2'
end
end
result = Interactor.perform(param_1: '1')
result.success? # false
Or with using the chain DSL. The chain DSL allows all power of Ni to comes in
class Interactor
include Ni::Main
receive :param_1
action :perform do
self.context.errors.add(:base, 'An error') if context.param_1 == '2'
end
end
result = Interactor.perform(param_1: '1')
result.success? # false
Interactor result
You can get any context value via the context method. But, there is also another method to read the context values with assign multiple variables
class Interactor
include Ni::Main
provide :a
provide :b
provide :c
action :perform do
context.a = 1
context.b = 2
context.c = 3
end
end
result = Interactor.perform
result.success? # true
result.context.a # 1
result.context.b # 2
result.context.c # 3
result, a, b, c = Interactor.perform
a # 1
b # 2
c # 3
But, if the list of provided values will be specified manually, only these params will be returned. Check the provide(:c)
part of the chain
class Interactor
include Ni::Main
provide :a
provide :b
provide :c
action :perform do
context.a = 1
context.b = 2
context.c = 3
end
.provide(:c)
end
result, c = Interactor.perform
result.context.a # 1
result.context.b # 2
c # 3
Interactor context and contracts
Shared state is one of the Interactor pattern cons. To control it Ni has access and contract DSL. You can't manipulate with the context data without explicitly defining your intentions.
The receive
allows to read from context.
The mutate
allows to read and write.
The provide
allows only to write.
Note! If there are no any rules then all access granted!
class Interactor1
include Ni::Main
action :perform do
context.param_1
end
end
class Interactor2
include Ni::Main
receive :param_1
action :perform do
context.param_2 = context.param_1
end
end
Interactor1.perform(param_1: 1) # will not raise any exceptions
Interactor2.perform(param_1: 1) # will raise "The `param_2` is not allowed to write"
Please note that defining read/write rules are not working as a contracts.
class Interactor
include Ni::Main
receive :param_1
mutate :param_2
provide :param_3
action :perform do
#do nothing
end
end
result = Interactor.perform
result.success? # true
You can define contracts with two different ways:
Method contracts
class Interactor
include Ni::Main
receive :param_1, :present?
mutate :param_2, :zero?
provide :param_3, :zero?
action :perform do
context.param_3 = 1
end
end
Interactor.perform # will raise "Value of `param_1` doesn't match to contract :present?"
Interactor.perform(param_1: true, param_2: 1) # will raise "Value of `param_2` doesn't match to contract :zero?"
Interactor.perform(param_1: true, param_2: 0) # will raise "Value of `param_3` doesn't match to contract :zero?"
Lambda contracts
class Interactor
include Ni::Main
receive :param_1, -> (val) { val == true }
mutate :param_2, -> (val) { val == 0 }
provide :param_3, -> (val) { val == 0 }
action :perform do
context.param_3 = 1
end
en
Interactor.perform # will raise "Value of `param_1` doesn't match to contract"
Interactor.perform(param_1: true, param_2: 1) # will raise "Value of `param_2` doesn't match to contract"
Interactor.perform(param_1: true, param_2: 0) # will raise "Value of `param_3` doesn't match to contract"
An actions DSL
class Interactor1
include Ni::Main
receive :param_1
action :perform do
self.context.errors.add(:base, 'An error') if context.param_1 == '2'
end
end
You can define new actions based on the existing ones
class Interactor
include Ni::Main
receive :custom_option
mutate :param_1
action :create do
if context.custom_option
context.param_1 = 'new_value_1'
else
context.param_1 = 'new_value'
end
end
def self.create!(params={})
create params.merge(custom_option: true)
end
end
result = Interactor.create!(param_1)
result.context.param_1 # new_value_1
Or, even redefine an interface
class Interactor
include Ni::Main
receive :custom_option
mutate :param_1
action :create do
if context.custom_option
context.param_1 = 'new_value_1'
else
context.param_1 = 'new_value'
end
end
def self.create(params={})
perform_custom :create, params.merge(custom_option: true)
end
end
result = Interactor.create!(param_1)
result.context.param_1 # new_value_1
There are two callbacks which called when need to check a condition on_checking_continue_signal
and on_continue_signal_checked
. You can use them to get an additional control for your flow
class Organizer
include Ni::Main
storage Ni::Storages::Default
Ni::Storages::ActiveRecordMetadataRepository
mutate :user_1
mutate :user_2
mutate :before_cheking
mutate :after_cheking
action :perform do
context.user_1 = User.create! email: 'user1@test.com', password: '111111'
end
.then(:create_second_user)
.wait_for(:outer_action_performed)
private
def on_checking_continue_signal(unit)
context.before_cheking = User.where(email: 'before@test.com').first_or_create! password: '111111'
end
def on_continue_signal_checked(unit, wait_cheking_result)
context.after_cheking = User.where(email: 'after@test.com').first_or_create! password: '111111'
end
def create_second_user
context.user_2 = User.create! email: 'user2@test.com', password: '111111'
end
end
Callbacks
When you use an action DSL you can define a callbacks. The Ni will call it automatically.
Note! All actions will have the same callbacks and will receive action name as an argument
class Interactor
include Ni::Main
mutate :param_1
mutate :param_2
mutate :param_3
def before_action(name)
if name == :perform
context.param_1 = 'perform_1'
else
context.param_1 = 'custom_perform_1'
end
end
def after_action(name)
if name == :perform
context.param_3 = 'perform_3'
else
context.param_3 = 'custom_perform_3'
end
end
action :perform do
context.param_2 = 'perform_2'
end
.provide(:param_1, :param_2, :param_3)
action :custom_perform do
context.param_2 = 'custom_perform_2'
end
.provide(:param_1, :param_2, :param_3)
end
result, param_1, param_2, param_3 = Interactor.perform
param_1 # 'perform_1'
param_2 # 'perform_2'
param_3 # 'perform_3'
result, param_1, param_2, param_3 = Interactor.custom_perform
param_1 # 'custom_perform_1'
param_2 # 'custom_perform_2'
param_3 # 'custom_perform_3'
Organizers
Ni allows to build your domain flow. It has a super simple DSL for organizing Interactors into a chains.
class Interactor2
include Ni::Main
receive :param_2
provide :param_3
def perform
context.param_3 = context.param_2 + 1
end
end
class Interactor3
include Ni::Main
receive :param_4
provide :param_5
action :custom_action do
context.param_5 = context.param_4 + 1
end
end
class Interactor
include Ni::Main
mutate :param_1
mutate :param_2
mutate :param_3
mutate :param_4
mutate :param_5
mutate :final_value
action :perform do
context.param_1 = 1
end
.then(:step_1)
.then(Interactor2)
.then do
context.param_4 = context.param_3 + 1
end
.then(Interactor3, :custom_action)
.then(:step_2)
.then do
context.final_value = context.param_5 + 1
end
private
def step_1
context.param_2 = context.param_1 + 1
end
def step_2
context.param_5 = context.param_4 + 1
end
end
result = Interactor.perform
result.context.param_1 # 1
result.context.param_2 # 2
result.context.param_3 # 3
result.context.param_4 # 4
result.context.param_5 # 5
result.context.final_value # 6
Context Isolation
Building application within the set of small reusable modules are good approach. This is exactly what interactor pattern means - follow the SRP principle. Use each Interactor independently or gather them into Organizer chain. The contracts will help you to keep your code solid.
But, there are a case, when a shared state could be an issue. Because the DSL allows you to build and combine interactors in any way you want with a single interface, you can face the situation when one chain will perform another one as it's step. So, in this case the step will be not a single Interactor, but a chain of the another ones.
And this second chain can contain interactors, which changes the same params. Or just contain the same Interactors.
In this case you can perform it in isolation.
class Interactor2
include Ni::Main
mutate :param_1
provide :param_2
action :custom_action do
context.param_2 = context.param_1 + 1
context.param_1 = 25
end
end
class Organizer1
include Ni::Main
mutate :param_1
mutate :param_2
mutate :param_3
action :perform do
# an empty initializer
end
.then(Interactor1)
.isolate(Interactor2, :custom_action, receive: [:param_1], provide: [:param_2])
.then(Interactor3)
end
The Interactor2
can't change param_1
value. The Interactor3
will receive original param_1
value.
Errors and Exceptions
The Ni has a DSL to control the flow and exceptions when performing the chain.
To stop the interaction execution just add an error to context. Also you could specify the failure callback
class Interactor1
include Ni::Main
def perform
context.errors.add :base, 'Something went wrong'
end
end
class Interactor2
include Ni::Main
provide :param_1
def perform
context.param_1 = 1
end
end
class Organizer1
include Ni::Main
provide :failure_value
action :perform do
# empty initializer
end
.then(Interactor1)
.then(Interactor2)
.failure do
context.failure_value = 'fail'
end
end
result = Organizer1.perform
result.success? # false
result.context.param_1 # nil
result.context.failure_value # 'fail'
Also allows to handle exceptions. Check the rescue_from
section. You can specify the exceptions handlers or specify the default handler for all exceptions.
class Ex1 < Exception
end
class Ex2 < Exception
end
class Interactor1
include Ni::Main
def perform
raise Es::Ex2.new
end
end
class Interactor2
include Ni::Main
provide :param_1
def perform
context.param_1 = 1
end
end
class Organizer1
include Ni::Main
provide :exception_value
action :perform do
# empty initializer
end
.then(Es::Interactor1)
.then(Es::Interactor2)
.rescue_from Es::Ex1, Es::Ex2 do
context.exception_value = 'fail'
end
end
class Organizer2
include Ni::Main
provide :exception_value
action :perform do
# empty initializer
end
.then(Es::Interactor1)
.then(Es::Interactor2)
.rescue_from do
context.exception_value = 'fail'
end
end
result = Organizer1.perform
result.success? # false
result.context.param_1 # eq nil
result.context.exception_value # 'fail'
Pause your flow
Ni allows you to pause your flow and continue it later. Ni will do the job, but it will require some configuration.
When chain meets the wait_for(:outer_action_performed)
it stops the chain performance. Then you can call the same method and pass two options to tell Ni that you want to continue your performance, but not to start a new one
Strict! The :outer_action_performed
not just a human readable name, but also an unique ID and it's global for the whole application. You can't use the same names in different interactors!
Organizer.perform(wait_completed_for: :outer_action_performed, system_uid: uid)
The uid you can get from the previous interactor execution. It's stored in context as system_uid
. It will not just continue the performance, but also will restore yours context, if you want.
Also you can set the multiple conditions and even add a dynamic condition.
wait_for(more_users_expected:
[
:moderator_user_registered,
[:user_registration, -> (context) { User.count >= 6 }]
]
)
The more_users_expected
is just a syntax key. To back to this steps you need to use a symbol names from the described conditions.
Organizer.perform(wait_completed_for: :moderator_user_registered, system_uid: uid)
Organizer.perform(wait_completed_for: :user_registration, system_uid: uid)
In the example above you need to pass the :user_registration
until you will have 6 users. But please be aware, you should stop calling the interactor by yourself, when the condition becomes wrong. For now Ni has no any internal logic to track when conditions becomes irrelevant
Also you need to know about multiple conditions it's that the Metadata Repository is required. What is it will be described right after the code example.
class Organizer
include Ni::Main
storage Ni::Storages::Default
Ni::Storages::ActiveRecordMetadataRepository
mutate :user_1
mutate :user_2
mutate :user_3
mutate :admin_user
mutate :done
action :perform do
context.user_1 = User.create! email: 'user1@test.com', password: '111111'
end
.then(:create_second_user)
.wait_for(:outer_action_performed)
.then(:create_third_user)
.wait_for(more_users_expected:
[
:moderator_user_registered,
[:user_registration, -> (context) { User.count >= 6 }]
]
)
.then(:create_admin)
.wait_for(:all_thigs_done)
.then do
context.done = true
end
private
def create_second_user
context.user_2 = User.create! email: 'user2@test.com', password: '111111'
end
def create_third_user
context.user_3 = User.create! email: 'user3@test.com', password: '111111'
end
def create_admin
context.admin_user = User.create! email: 'admin@test.com', password: '111111'
end
end
first_result = Organizer.perform.context
uid = first_result.system_uid
first_result.user_1.email # 'user1@test.com'
first_result.user_2.email #'user2@test.com'
first_result.user_3 # nil
second_result = Organizer.perform(wait_completed_for: :outer_action_performed, system_uid: uid).context
second_result.user_1.email # 'user1@test.com'
second_result.user_2.email # 'user2@test.com'
second_result.user_3.email # 'user3@test.com'
second_result.admin_user # nil
User.create! email: 'moderator@test.com', password: '111111'
# The third result will be the same because users count less then 6
third_result = Organizer.perform(wait_completed_for: :moderator_user_registered, system_uid: uid).context
third_result.user_1.email # 'user1@test.com'
third_result.user_2.email # 'user2@test.com'
third_result.user_3.email # 'user3@test.com'
third_result.admin_user # nil
User.create! email: 'user4@test.com', password: '111111'
# The fourth result will be the same because need one more user
fourth_result = Organizer.perform(wait_completed_for: :user_registration, system_uid: uid).context
fourth_result.user_1.email # 'user1@test.com'
fourth_result.user_2.email # 'user2@test.com'
fourth_result.user_3.email # 'user3@test.com'
fourth_result.admin_user # nil
User.create! email: 'user5@test.com', password: '111111'
# Now the admin creation is available
result = Organizer.perform(wait_completed_for: :user_registration, system_uid: uid).context
result.user_1.email # 'user1@test.com'
result.user_2.email # 'user2@test.com'
result.user_3.email # 'user3@test.com'
result.admin_user.email # 'admin@test.com'
result.done # nil
# This last step checks that skip for multiple conditions work as well
result = Organizer.perform(wait_completed_for: :all_thigs_done, system_uid: uid).context
result.done # true
It's smart enough to get, that the wait part is not in the top level flow. For example Organizer1 calls Organizer2 as a step and Organizer2 has the wait_for
part.
More detailed example. The Organizer interactor uses the OrganizerLevel2 one. The OrganizerLevel2 interactor uses the OrganizerLevel3 one. Each of them has own wait_for
logic. And it works correct, because Ni recursively parse the interactors tree and use it to control it's flow.
class OrganizerLevel3
include Ni::Main
storage Ni::Storages::Default
Ni::Storages::ActiveRecordMetadataRepository
mutate :user_3
action :perform do
# empty initializer
end
.wait_for(:ready_create_third_user)
.then do
context.user_3 = User.create! email: 'user3@test.com', password: '111111'
end
end
class OrganizerLevel2
include Ni::Main
storage Ni::Storages::Default
Ni::Storages::ActiveRecordMetadataRepository
mutate :user_2
action :perform do
# empty initializer
end
.wait_for(:ready_create_second_user)
.then do
context.user_2 = User.create! email: 'user2@test.com', password: '111111'
end
.then(OrganizerLevel3)
end
class Organizer
include Ni::Main
storage Ni::Storages::Default
Ni::Storages::ActiveRecordMetadataRepository
mutate :user_1
mutate :user_2
mutate :user_3
mutate :admin_user
mutate :done
action :perform do
context.user_1 = User.create! email: 'user1@test.com', password: '111111'
end
.then(OrganizerLevel2)
.wait_for(:ready_create_admin)
.then(:create_admin)
.wait_for(:final_step)
.then do
context.done = true
end
private
def create_admin
context.admin_user = User.create! email: 'admin@test.com', password: '111111'
end
end
first_result = Sowf::Organizer.perform.context
uid = first_result.system_uid
first_result.user_1.email # 'user1@test.com'
first_result.user_2 # nil
first_result.user_3 # nil
second_result = Sowf::Organizer.perform(wait_completed_for: :ready_create_second_user, system_uid: uid).context
second_result.user_1.email # 'user1@test.com'
second_result.user_2.email # 'user2@test.com'
second_result.user_3 # nil
second_result.admin_user # nil
second_result = Sowf::Organizer.perform(wait_completed_for: :ready_create_third_user, system_uid: uid).context
second_result.user_1.email # 'user1@test.com'
second_result.user_2.email # 'user2@test.com'
second_result.user_3.email # 'user3@test.com'
second_result.admin_user # nil
# Now the admin creation is available
result = Sowf::Organizer.perform(wait_completed_for: :ready_create_admin, system_uid: uid).context
result.user_1.email # 'user1@test.com'
result.user_2.email # 'user2@test.com'
result.user_3.email # 'user3@test.com'
result.admin_user.email # 'admin@test.com'
result.done # nil
# This last step checks that skip for multiple conditions work as well
result = Sowf::Organizer.perform(wait_completed_for: :final_step, system_uid: uid).context
result.done # true
So, in this example you may notice the two new configurations: Storage and Metadata Repository.
storage Ni::Storages::Default
Ni::Storages::ActiveRecordMetadataRepository
The Storage class implements logic for storing your context and restoring it. There is a default storage Ni::Storages::Default
you can use. For now it supports only the ActiveRecord records and collections, relations and so one.
But it's easy to implement your own one. Just create a new class, inherite from Ni::Storages::Default
. And you will have a two option, how to work with context. Check the storages/default_spec.rb
to get an examples.
The Metadata Repository needed to store some metadata. I.e. for Storage it will store the metadata, which will allow to restore you context. For the multiple wait_for it will store the list of already passed conditions.
Ni has an implemented ActiveRecord repository, which can be used with rails. Just create a relavant table for it.
create_table :ni_metadata do |t|
t.string :uid, null: false
t.string :key, null: false
t.datetime :run_timer_at
t.text :data
t.
end
add_index :ni_metadata, [:uid, :key], unique: true
These classes has a pretty simple interfaces, so it will be easy to develop your own ones for your needs.
Unique IDs
Interactors can have an explicit unique_id
. This will allow to use an identifier when building your flow. I.e. the WaitFor could receive interactor class as an option, if the unique id was provided.
Unique_id should be a symbol. By default it's a class name.
class Interactor1
include Ni::Interactor
end
class Interactor2
include Ni::Interactor
unique_id :some_unique_id
end
Interactor1.interactor_id # 'Interactor1'
Interactor1.interactor_id! # Will raise "The Interactor1 requires an explicit definition of the unique id"
Interactor2.interactor_id # :some_unique_id
Interactor2.interactor_id! # :some_unique_id
The WaitFor example
class ExternalThirdUser
include Ni::Main
unique_id :external_third_user
action :perform do
end
end
class OrganizerLevel3
include Ni::Main
storage Ni::Storages::Default
Ni::Storages::ActiveRecordMetadataRepository
mutate :user_3
action :perform do
# empty initializer
end
.wait_for(Sowf::ExternalThirdUser)
.then do
context.user_3 = User.create! email: 'user3@test.com', password: '111111'
end
end
It doesn't matter how to continue chain, by a symbol ID or providing a class
Flow branches
Describing a flow you may face with the situation when flow splits to several branches and it depends on some conditions which one will be performed.
There are two ways to define a branch.
- Use an existing Interactor.
- Use a branch id
action :perform do
end
.branch(SomeExistingInteractor, when: -> (context) { context.param_1 == 666 })
.branch(:first_level_valid_branch, when: -> (context) { context.param_1 == 1 }) do
receive :param_1
action :perform do
end
end
In both cases you need to specify a when condition by defining a lambda. Branches supports all features of the interactors, like WaitFor and others.
There are some pitfals with branches. Because all interactors share a single state, the first branch may change the context and made the second one also valid
More detailed example
class Level1NotUsedBranch
include Ni::Main
def perform
raise 'Should not be here'
end
end
class Level2NotUsedBranch
include Ni::Main
def perform
raise 'Should not be here'
end
end
class Organizer1
include Ni::Main
mutate :param_1
action :perform do
context.param_1 = 1
end
.branch(Level1NotUsedBranch, when: -> (context) { context.param_1 == 666 })
.branch :first_level_valid_branch, when: -> (context) { context.param_1 == 1 } do
receive :param_1
action :perform do
end
.branch :second_level_valid_branch, when: -> (context) { context.param_1 == 1 } do
mutate :param_1
action :perform do
context.param_1 = 2
end
end
.branch(Level2NotUsedBranch, when: -> (context) { context.param_1 == 666 })
end
end
expect(Organizer1.perform.context.param_1 # 2
TODO:
Continue logic:
- For now Ni has no any internal logic to track when conditions becomes irrelevant Parallel execution Implement some wrap logic. I.e. a way to put operations in transaction Ensure all features will also work for the Ancestors
Allow to break execution with success or failure, cancel or terminate
- fix tests
- Chain methods
- Inline flow + Branches
- Refactoring to allow just a simple methods, not only from chain
class Organizer1
include Ni::Main
provide :failure_value
storage CustomStorage
MetadataRepository
action :perform do
# empty initializer
end
.then(Interactor1, on_cancel: CancelInteractor, on_failure: FailureInteractor, on_terminate: TerminateInteractor) # Same for branches
.wait_for(:interactor2_ready)
.then(Interactor2)
.async(continue_from: :interactor10_ready,
steps: [
Interactor3,
[Intreractor4, :custom_action]
]
)
.wait_for(:interactor10_ready)
.handoff_to('Remote microservice',
via: MyHTTPChannel,
continue_from: :interactor11_ready
)
.wait_for(:interactor11_ready)
.then(Interactor11)
.branch :my_new_branch, when: -> (context) { context.param_1 == 1 } do
action :perform do
end
.then(Interactor12)
.wait_for(:interactor13_ready)
.then(Interactor13)
.cancel!
end
.branch :other_branch, when: -> (context) { context.param_1 == 2 } do
action :perform do
end
.then(Interactor14)
.branch :inner_branch, when: -> (context) { context.param_1 == 1 } do
action :perform do
end
.then(Interactor15)
end
.branch(Intreractor4, :custom_action, when: -> (context) { context.param_1 == 1 })
.terminate!
end
.wait_for(
all_ready: [
:interactor15,
[:interactor16, -> (context) { context.param_1 == '10' } ],
:interactor17
],
timer: [ -> { 10.minutes.from_now }, InteractorTimer, :custom]
)
.failure do
context.failure_value = 'fail'
end
end