rmlockerd/laminar

View on GitHub
README.md

Summary

Maintainability
Test Coverage
# Laminar

[![Gem Version](https://badge.fury.io/rb/laminar.svg)](https://badge.fury.io/rb/laminar)
[![Build Status](https://travis-ci.org/rmlockerd/laminar.svg?branch=master)](https://travis-ci.org/rmlockerd/laminar)
[![Maintainability](https://api.codeclimate.com/v1/badges/6b2d761ca042af6461e3/maintainability)](https://codeclimate.com/github/rmlockerd/laminar/maintainability)
[![Test Coverage](https://api.codeclimate.com/v1/badges/6b2d761ca042af6461e3/test_coverage)](https://codeclimate.com/github/rmlockerd/laminar/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:

```ruby
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.

```ruby
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.
```ruby
ChangeAddress.call(user: user, new_address: addr)
```

The invoked particle accesses its context within its `.call` method like  normal Hash:
```ruby
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.

```ruby
// 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:
```ruby
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.
```ruby
context.fail!
```

There are also convenience methods for checking success/failure:
```ruby
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:
```ruby
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.

```ruby
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`.

```ruby
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.

```ruby
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.
```ruby
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:

```ruby
// 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:

```ruby
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:

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

```ruby
  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.

```ruby
  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.

```ruby
  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.

```ruby
  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:

```ruby
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](http://contributor-covenant.org) code
of conduct.

## License

The gem is available as open source under the terms of the
[MIT License](https://opensource.org/licenses/MIT).

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