Project

laminar

0.0
No commit activity in last 3 years
No release in over 3 years
Simple, composable business objects & workflow
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

~> 1.16
~> 12.3, >= 12.3.3
~> 3.0
~> 0.16
 Project Readme

Laminar

Gem Version Build Status Maintainability Test Coverage

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).