Laminar
A simple Chain-of-Responsibility/Interactor gem that helps MVC applications organise their business logic, keeping their models and controllers skinny and their logic easily testable. Individual chunks of business logic (called particles) can be easily composed into more complex chains called flows.
Installation
Add this line to your application's Gemfile:
gem 'laminar'
And then execute:
$ bundle
Or install it yourself as:
$ gem install laminar
Usage
'Skinny' Controllers AND 'Skinny' Models
Even if you are reasonably new to Model-View-Controller (MVC) frameworks, such as Ruby on Rails, you have likely encountered the advice to have 'skinny controllers, fat models'. 'Skinny' controllers (i.e., simple, small, single-responsibility) are indeed best, but pushing a lot of code into your models has its own issues, and it isn't in reality an either/or choice.
Separating your business logic into single-purpose service objects (also sometimes called 'interactors' and several other names) helps keep your models and controllers skinny, and your code DRY and more easily testable.
Particles
A particle is a PORO (Plain Old Ruby Object) that encapsulates a piece of your business logic. Keeping with the Single Responsibility Principle, a particle should preferably do only one thing.
Defining a Particle
A particle is a plain Ruby class that includes Laminar::Particle
and defines a call
method.
class ChangeAddress
include Laminar::Particle
def call
// change address logic goes here
end
end
Particle Context
To invoke a particle, invoke the .call
method on the particle's
class object, passing a Hash of values that is the 'context' in which
the particle runs.
ChangeAddress.call(user: user, new_address: addr)
The invoked particle accesses its context within its .call
method like normal Hash:
sku = context[:product_sku]
The particle can also add to or modify the context. The context is returned to the invoker, which can then access the modified context.
// Particle
class OpenTicket
include Laminar::Particle
def call
context[:status] = :pending
end
end
// Caller
result = OpenTicket.call
if result[:status] != :pending
...
end
Keyword Arguments
You can also use keyword arguments particle's .call
method:
class ChangeAddress
include Laminar::Particle
def call(user:, new_address:)
// change address logic goes here
end
end
When you declare keyword arguments, Laminar passes matching values
from the context to your particle. This can make your particle more
self-documenting and provides a simple ArgumentError
exception if the
calling context does not contain the minimum information required for the
particle to function.
The .call
implementation always has access to the full context via
context
whether or not you declare keyword arguments.
Particle Success / Failure
Particles have a simple mechanism for flagging success and
failure. To signal failure, simply call .fail!
on the context.
context.fail!
There are also convenience methods for checking success/failure:
context.success? # => true by default
context.failed? # => false
context.fail!
context.halted? # => true
context.failed? # => true
context.success? # => false
The .fail!
method accepts a hash that is merged into the context,
making it convenient to attach error information:
context.fail!(error: 'The user is allergic to bananas!')
Use the .halt!
class method to immediately stop a particle without marking it as a failure.
context.halt!
context.halted? # => true
context.success? # => true
context.failed? # => false
The .halt!
accepts a context hash similar to .fail!
.
Callbacks
Particles can specify one or more callbacks to execute immediately before or after the invocation of its #call
.
class Foo
include Laminar::Flow
before :setup # method symbol
before { ... } # block
after :teardown # method symbol
after { ... } # block
Callbacks execute in the order they are specified when there are multiple of the same kind.
Flows
A flow is a chained sequence of particles, creating a simple workflow. Each step (particle) contributes to an overall outcome through a shared context. Simple branching and looping is supported via conditional logic.
A flow includes Laminar::Flow
, which provides a DSL for specifying
the particles to execute. The most basic flow is a simple set of steps executed sequentially.
class FillCavity
include Laminar::Flow
flow do
step :numb_mouth
step :drill_cavity
step :apply_amalgam
end
end
A step label must be a symbol that identifies a Particle. By default,
the Flow assumes the step label is the implementation class name (i.e.,
:numb_mouth
-> NumbMouth
).
You can use the class:
directive to specify an alternate class
name. Very useful when your particles
are organised into modules.
class FillCavity
include Laminar::Flow
flow do
step :numb_mouth
step :drill_cavity, class: 'Dentist::Drill'
step :apply_amalgam
end
end
Invoking a Flow
Flows behave exactly like Particles in terms of execution. To start
a Flow, call .call
on the Flow class, passing a Hash of context:
// Flow
class FillCavity
include Laminar::Flow
flow do
step :numb_mouth
step :drill_cavity, class: 'Dentist::Drill'
step :apply_amalgam
end
end
// Caller
result = FillCavity.call(patient: patient, tooth_number: tooth)
A Flow returns the context as it stands after the final step in the Flow ends. Because Flows behave exactly like Particles, they can be nested as steps inside other flows without issue:
class FillCavity
include Laminar::Flow
flow do
step :check_equipment # Flow
...
end
end
class CheckEquipment
include Laminar::Flow
flow do
...
end
end
Flow Parameters
Flows do not get the benefit of keyword argument checking like ordinary Particles, since their #call method is implemented by the Flow mixin. You can, however, specify a list of required context keys in the flow definition itself:
flow do
context_must_have :product_sku, :unit_price
...
end
The context is simply checked for the presence of the specified
list of keys. If you need to do more complicated validation of
context, use a #before
callback that halts the flow if
validation fails.
Context validation happens just prior to execution of the
first step in the flow (and any #before_each
) callbacks, but after
execution of the Flow's own #before
callbacks. Since you can
manipulate context in a callback, this is useful to set up
context required by a flow's particles that you don't necessarily
expect your caller to provide.
Flow Branching
Ordinarily particle execution is sequential in the order specified.
However, you can optionally branch to a different label with branch
.
flow do
step :do_something do
branch :final_step
end
step :another_step # skipped
step :final_step
end
You can use the endflow directive to terminate the flow gracefully (skipping all remaining steps).
flow do
step :do_something do
branch :endflow
end
step :another_step # skipped
step :final_step # skipped
end
Conditional Branching
Branches can be made conditional with the if:
and unless:
directives.
flow do
step :first do
branch :last_step, if: :done_early?
end
step :then_me
step :do_something
step :last_step
end
The target of if:
or unless:
is a symbol naming a method on the invoking Flow.
flow do
step :first do
branch :last_step, if: :done_early?
end
...
end
def done_early?
!context[:finished].nil? && context[:finished] == true
end
A step can have multiple branch directives; the flow will take the first branch that it finds that satisfies its specified condition (if any). If no condition is satisfied, execution drops to the next step.
flow do
step :first do
branch :last_step, if: :condition1?
branch :do_something, if: :condition2?
end
step :then_me # executed if neither condition1 nor condition2
step :do_something
step :last_step
end
Halting a Flow
If a particle calls #halt!
or #fail!
on its context, execution
of any surrounding Flow (or nested flows) stops immediately via a
ParticleStopped
error. To gracefully signal that an enclosing
flow should stop without raising an error, use #halt
instead.
Flow Callbacks
A flow can specify callback(s) to run before/after every step:
class MyFlow
include Laminar::Flow
flow do
before_each :thing, :thing2 # method
before_each { ... } # block
after_each :thing, :thing2 # method
after_each { ... } # block
# steps ...
end
The order of execution for callbacks in a flow looks like:
flow's before
flow's before_each
step1's before
<step1 invoked>
step1's after
flow's after_each
flow's after
Testing Particles and Flows
TODO
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/rmlockerd/laminar. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Laminar project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct] (https://github.com/rmlockerd/laminar/blob/master/CODE_OF_CONDUCT.md).