Theme: 

Rung

CircleCI Maintainability

Rung is service object/business operation/Railway DSL.

This is a lightweight, independent alternative to Trailblazer Operation and dry-transaction.

Installation

Add this line to your application’s Gemfile:

gem 'rung'

And then execute:

$ bundle

Or install it yourself as:

$ gem install rung

Example Usage

Example:

class CreateOrder < Rung::Operation
  step do |state|
    state[:order_id] = "order-#{SecureRandom.uuid }"
  end
  step ValidateMagazineState
  step :log_start

  step WithBenchmark do
    step CreateTemporaryOrder
    step :place_order
  end

  step :log_success
  failure :log_failure

  def log_start(state)
    state[:logger].log("Creating order #{state[:order_id]}")
  end

  def log_success(state)
    state[:logger].log("Order #{state[:order_id]} created successfully")
  end

  def log_failure(state)
    state[:logger].log("Order #{state[:order_id]} not created")
  end

  def place_order(state)
    status = OrdersRepository.create(state[:order_id])

    # Step return value is important.
    # If step returns falsy value then the operation is considered as a failure.
    status == :success
  end
end

result = CreateOrder.call(logger: Rails.logger)
if result.success?
  print "Created order #{result[:order_id]}"
end

Docs

Docs: https://gogiel.github.io/rung/ Generated from Cucumber specifications using Cukedoctor.

Cucumber docs can be generated locally using $ rake docker_generate_docs. It requires Docker.

Development

After checking out the repo, run bundle to install dependencies. Then, run rake to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/gogiel/rung.

License

The gem is available as open source under the terms of the MIT License.

Operation definition

Operation defines a business process that consists of multiple steps.

For example when in e-commerce application new order is created then the system should update state of the warehouse, send an e-mail, create new waybill etc.

To define Operation create a new class based on Rung::Operation. Inside it you can define steps using Rung DSL. Steps definition order is important as they are always executed in order.

Steps can communicate with each other and the external world using State. When operation is called then new state object is created. State is shared between step executions and available as a result of operation. See State chapter to learn more.

There are multiple ways of defining steps:

  • using a block

  • Symbol with a method name

  • using object that responds to .call

Each method can be used with a an argument (state) or with no arguments.

Using block notation is not advised. It’s made primarily for debugging.

Steps can be defined as a Ruby block

Given

definition

class Operation < Rung::Operation
  step do |state|
    state[:what] = "World"
  end

  step do
    print_to_output "Hello "
  end

  step do |state|
    print_to_output state[:what]
  end

  step do
    print_to_output "!"
  end
end
When

I run

Operation.new.call
Then

I see output

Hello World!

Steps can be defined as methods

Given

definition

class Operation < Rung::Operation
  step :set_what_state
  step :print_hello
  step "print_what"
  step :print_bang

  def set_what_state(state)
    state[:what] = "World"
  end

  def print_hello
    print_to_output "Hello "
  end

  def print_what(state)
    print_to_output state[:what]
  end

  def print_bang
    print_to_output "!"
  end
end
When

I run

Operation.new.call
Then

I see output

Hello World!

Steps can be defined as any objects with call method

Given

definition

class SetWhatState
  def initialize(what)
    @what = what
  end

  def call(state)
    state[:what] = @what
  end
end

class PrintHello
  def self.call
    print_to_output "Hello "
  end
end

class PrintWhat
  def self.call(state)
    print_to_output state[:what]
  end
end

PrintBang = -> { print_to_output "!" }

class Operation < Rung::Operation
  step SetWhatState.new("World")
  step PrintHello
  step PrintWhat
  step PrintBang
end
When

I run

Operation.new.call
Then

I see output

Hello World!

State

State is a Hash object that is shared between step executions.

It’s used to share state between steps and communicate with external world.

User can provide initial state when calling the operation. By default it’s empty.

State can be used as the operation output as it is accessible in the result object.

State is shared across step executions

Given

definition

class Operation < Rung::Operation
  step do |state|
    state[:what] = "World!"
  end

  step do
    print_to_output "Hello "
  end

  step do |state|
    print_to_output state[:what]
  end
end
When

I run

Operation.new.call
Then

I see output

Hello World!

State is available in the result object

Given

definition

class Operation < Rung::Operation
  step do |state|
    state[:output_text] = "Hello "
  end

  step do |state|
    state[:output_text] << "World!"
  end
end
When

I run

@result = Operation.new.call
Then

I can assure that

@result[:output_text] == "Hello World!"

Initial state can be passed to call method

Given

definition

class Operation < Rung::Operation
  step do |state|
    state[:output_text] << "World!"
  end
end
When

I run

@result = Operation.new.call(output_text: "Hello ")
Then

I can assure that

@result[:output_text] == "Hello World!"
And

I can assure that

@result.to_h == { output_text: "Hello World!" }

Success and failure

Rung gem is based on Railway oriented programming idea. If you are not familiar with this concept I highly recommend watching Scott Wlaschin’s presentation first.

Value returned from step call is important. Successful step should return truthy value (anything other than false or nil).

If step returns falsy value (false or nil) then the operation is marked as failed. All next steps are not executed.

Result of the Operation call (Rung::State object) can be either a success or a failure. It can be checked using State has success? and fail? (with failed?, failure? aliases) methods.

When all steps return truthy value the result is a success

Given

definition

class Operation < Rung::Operation
  step do
    # do something...
    true
  end

  step :second_step

  def second_step
    2 + 2
  end
end
When

I run

@result = Operation.new.call
Then

I can assure that

@result.success? == true
And

I can assure that

@result.failure? == false
And

I can assure that fail? alias works

@result.fail? == false
And

I can assure that failed? alias works

@result.failed? == false

When at least one step returns a falsy value then the result is a failure

Given

definition

class Operation < Rung::Operation
  step do
    # do something...
    true
  end

  step :second_step

  def second_step
    nil
  end
end
When

I run

@result = Operation.new.call
Then

I can assure that

@result.success? == false
And

I can assure that

@result.failure? == true

When a step fails then the next steps are not executed

Given

definition

class Operation < Rung::Operation
  step do
    print_to_output "Hello"
    true
  end

  step do
    # something went wrong, retuning false
    false
  end

  step do
    print_to_output "World"
  end
end
When

I run

Operation.new.call
Then

I see output

Hello

Failure Step

When operations fails next normal steps are no ignored and not executed.

There’s a way to react to a failure with special failure steps.

Failure steps can be defined similarly to normal steps (as a block, method, or a callable object). They are only executed when operation has failed.

Failure step is executed when operation fails

Given

definition

class Operation < Rung::Operation
  step do
    print_to_output "Working..."
    # something went wrong...
    false
  end

  failure do
    print_to_output " Oops!"
  end

  step do
    print_to_output "This won't be executed"
  end
end
When

I run

Operation.new.call
Then

I see output

Working... Oops!

Failure step is not executed when operation doesn’t fail

Given

definition

class Operation < Rung::Operation
  step do
    print_to_output "Working..."
    # everything's fine
    true
  end

  failure do
    print_to_output " Oops!"
  end
end
When

I run

Operation.new.call
Then

I see output

Working...

It’s possible to define multiple failure steps

Given

definition

class Operation < Rung::Operation
  step do |state|
    print_to_output "Working..."
    # something went wrong...
    state[:error] = "404"
    false
  end

  failure do
    print_to_output " Error: "
  end

  failure do |state|
    print_to_output state[:error]
  end
end
When

I run

Operation.new.call
Then

I see output

Working... Error: 404

Failure step is executed only when it’s defined after failed step

Given

definition

class Operation < Rung::Operation
  failure do
    print_to_output "Something's wrong"
  end

  step do
    print_to_output "Working..."
    # something went wrong...
    false
  end

  failure do
    print_to_output " Oops!"
  end
end
When

I run

Operation.new.call
Then

I see output

Working... Oops!

Other steps

step and failure are the basic step types. For the convenience there are also two additional step types defined:

  • tee - works the same way as step but ignores the return value

  • always - executes on both success and failure. Return value is not checked

Table 1. Table Step behaviours
Step name Execute on success Execute on failure Ignore return value

step

failure

tee

always

tee step result is not checked

Given

definition

class Operation < Rung::Operation
  tee do
    print_to_output "Hello "
    false
  end

  step do
    print_to_output "World!"
  end
end
When

I run

Operation.new.call
Then

I see output

Hello World!

tee is not executed when operation is failed

Given

definition

class Operation < Rung::Operation
  step do
    print_to_output "Hello"
    false # fail
  end

  tee do
    print_to_output "World!"
  end
end
When

I run

Operation.new.call
Then

I see output

Hello

always is called when operation is successful

Given

definition

class Operation < Rung::Operation
  step do
    print_to_output "Hello "
  end

  always do
    print_to_output "World!"
  end
end
When

I run

Operation.new.call
Then

I see output

Hello World!

always is called when operation is failed

Given

definition

class Operation < Rung::Operation
  step do
    print_to_output "Hello "
    false # fail
  end

  always do
    print_to_output "World!"
  end
end
When

I run

Operation.new.call
Then

I see output

Hello World!

state object provides information about operation success with success? and fail?

Given

definition

class Operation < Rung::Operation
  step do
    print_to_output "Hello"
    false # fail
  end

  always do |state|
    print_to_output " World!" if state.success?
    print_to_output " There!" if state.fail?
  end
end
When

I run

Operation.new.call
Then

I see output

Hello There!

Fail fast

When a fail-fast step is executed and the operation is a failure then no next step is executed, including always and failure steps.

It doesn’t matter if a failure is caused by the fail-fast step itself or if it was caused by any previous step, it behaves the same way.

When step with fail_fast fails the execution is immediately stopped

Given

definition

class Operation < Rung::Operation
  step(fail_fast: true) do
    print_to_output "Hello"
    false
  end

  step do
    print_to_output " World!"
  end
end
When

I run

@result = Operation.new.call
Then

I see output

Hello
And

I can assure that

@result.failure?

Any kind of step can use fail_fast

Given

definition

class Operation < Rung::Operation
  step do
    print_to_output "Hello"
    false
  end

  failure fail_fast: true do
    print_to_output "...Goodbye!"
  end

  step do
    print_to_output " World!"
  end
end
When

I run

@result = Operation.new.call
Then

I see output

Hello...Goodbye!
And

I can assure that

@result.failure?

Nested operations

Nested Operation can be executed using nested helper

Current state is passed to the nested step.
Operation output is merged to the caller state.
Given

definition

class InnerOperation < Rung::Operation
  step do |state|
    state[:output] = state[:input] + 1
  end
end

class Operation < Rung::Operation
  step nested(InnerOperation)
end
When

I run

@result = Operation.call(input: 1)
Then

I can assure that

@result[:output] == 2

Nested Operation failure is treated as a step failure

Given

definition

class InnerOperation < Rung::Operation
  step do
    false # fail
  end
end

class Operation < Rung::Operation
  step nested(InnerOperation)
end
When

I run

@result = Operation.call(input: 1)
Then

I can assure that

@result.failure?

Nested Operation input and output can be re-mapped

Given

definition

class InnerOperation < Rung::Operation
  step do |state|
    state[:output] = state[:input] + 1
  end
end

class Operation < Rung::Operation
  step nested(InnerOperation,
    input: ->(state) { {input: state[:value] } },
    output: ->(state) { { calculated: state[:output] } }
  )

  step do |state|
    state[:sum] = state[:calculated] + state[:value]
  end
end
When

I run

@result = Operation.call(value: 1)
Then

I can assure that

@result.to_h == { calculated: 2, value: 1, sum: 3 }

Step wrappers

It’s possible to step around a group of successive steps. This is a common use case for surrounding multiple steps in a database transaction and rolling back when steps inside fail.

Steps wrapper is defined as a step (of any type, e.g. step or tee) with callable wrapper-action and additional block defining nested steps.

Wrapper-action is a Callable or method name.

Yielding the block calls inner steps. Yield returns false if any of the inner steps failed or true otherwise.

Example usage:

class Operation < Rung::Operation
  class TransactionWrapper
    def self.call(state)
      return if state.fail?
      ActiveRecord::Base.transaction do
        success = yield
        raise ActiveRecord::Rollback unless success
        true
      end
    end
  end

  step TransactionWrapper do
    step :create_new_entity
    step :update_counter
  end
end

Wrapper can yield to execute inner steps

Given

definition

class Operation < Rung::Operation
  class Wrapper
    def self.call
      print_to_output "Starting\n"
      success = yield
      if success
        print_to_output "\nSuccess!"
      else
        print_to_output "\nFailure!"
      end
    end
  end

  step Wrapper do
    step { print_to_output "Hello " }
    step do |state|
      print_to_output "World!"
      state[:variable] # return variable passed by the user
    end
  end
end
When

I run

Operation.new.call(variable: true)
Then

I see output

Starting
Hello World!
Success!
Then

I clear output

When

I run

Operation.new.call(variable: false)
Then

I see output

Starting
Hello World!
Failure!

Wrappers can be nested

Given

definition

class Operation < Rung::Operation
  class Wrapper
    def initialize(name)
      @name = name
    end

    def call
      print_to_output "Starting #{@name}\n"
      yield
      print_to_output "Finishing #{@name}\n"
    end
  end

  step Wrapper.new("first") do
    step { print_to_output "Hello\n" }
    step Wrapper.new("second") do
      step { print_to_output "Hi\n" }
    end
  end
end
When

I run

Operation.new.call
Then

I see output

Starting first
Hello
Starting second
Hi
Finishing second
Finishing first

step type is important when calling a wrapper

Given

definition

class Operation < Rung::Operation
  class Wrapper
    def initialize(name)
      @name = name
    end

    def call
      print_to_output "from: #{@name}\n"
      yield
    end
  end

  step do
    print_to_output "RED ALERT\n"
    false
  end

  step Wrapper.new("OK") do
    step { print_to_output "Hurray!\n" }
  end

  failure Wrapper.new("FAIL") do
    failure { print_to_output "Oops!\n" }
  end

  always Wrapper.new("ALWAYS") do
    step { print_to_output "We're done!\n" }
    failure { print_to_output "We're done, but something went wrong!\n" }
  end
end
When

I run

Operation.new.call(variable: true)
Then

I see output

RED ALERT
from: FAIL
Oops!
from: ALWAYS
We're done, but something went wrong!

Operation wrappers

It is possible to define global wrappers on the Operation level using around call.

Operation can have multiple global wrappers

Wrappers are called in the order they are defined.
Given

definition

class Operation < Rung::Operation
  class Wrapper
    def initialize(name)
      @name = name
    end

    def call
      print_to_output "#{@name} start\n"
      yield
      print_to_output "#{@name} done\n"
    end
  end

  around Wrapper.new("1")
  around Wrapper.new("2")

  step { print_to_output "Hello World!\n" }
end
When

I run

Operation.new.call(variable: true)
Then

I see output

1 start
2 start
Hello World!
2 done
1 done

Exceptions handling

Rung doesn’t provide any built-in exception handling. If you need to catch any exceptions you can implement error catching using a wrapper on a desired execution level.

All exceptions are raised

Given

definition

class Operation < Rung::Operation
  step { raise "Oh no!" }
end
When

I run

begin
  Operation.new.call(output_text: "Hello ")
rescue => e
  print_to_output e.message
end
Then

I see output

Oh no!

Exception can be caught in a wrapper

Given

definition

class Operation < Rung::Operation
  class Wrapper
    def self.call(state)
      yield
    rescue
      print_to_output "Exception handled"
      state[:exception_handled] = true
    end
  end

  around Wrapper

  step { print_to_output "Hello World!\n"; raise "oops!" }
end
When

I run

@result = Operation.new.call
Then

I see output

Hello World!
Exception handled
And

I can assure that

@result.to_h == { exception_handled: true }

Around step wrapper

Step wrapper is called for every step

Given

definition

class Operation < Rung::Operation
  class Counter
    def initialize
      @count = 0
    end

    def call
      @count += 1
      print_to_output "Step #{@count}\n"
      yield
    end
  end

  around_each Counter.new

  step do
    print_to_output "Hello\n"
  end

  step do
    print_to_output "World\n"
  end
end
When

I run

Operation.new.call
Then

I see output

Step 1
Hello
Step 2
World

Step wrapper receives state and current step

Given

definition

class Operation < Rung::Operation
  class Logger
    def self.call(state, step)
      result = yield

      print_to_output "State: #{state.to_h}, success: #{state.success?}," \
        "ignores result: #{step.ignore_result?}, nested: #{step.nested?}, result: #{result}\n"

      result
    end
  end

  noop_wrapper = -> (&block) { block.call }

  around_each Logger

  step do |state|
    print_to_output "Hello\n"
    state[:test] = 42
  end

  step noop_wrapper do
    tee do |state|
      state[:test] = 5
      print_to_output "World\n"
      true
    end
  end
end
When

I run

Operation.new.call
Then

I see output

Hello
State: {:test=>42}, success: true,ignores result: false, nested: false, result: 42
World
State: {:test=>5}, success: true,ignores result: true, nested: false, result: true
State: {:test=>5}, success: true,ignores result: false, nested: true, result: true

Step wrapper returned result is treated as the step result

Given

definition

class Operation < Rung::Operation
  class Boom
    def self.call(state)
      yield
      print_to_output "Boom!\n"
      false # always return false
    end
  end

  around_each Boom

  tee do |state| # tee step ignores result, so Boom doesn't affect it
    print_to_output "Hello\n"
  end

  step { print_to_output "Beautiful\n" }
  step { print_to_output "World\n" }
end
When

I run

@result = Operation.new.call
Then

I see output

Hello
Boom!
Beautiful
Boom!
And

I can assure that

@result.success? == false

Misc

Operation defines .call shorthand class method

Given

definition

class Operation < Rung::Operation
  step {|state| state[:test] = 42 + state[:input] }
end
When

I run

@result1 = Operation.call(input: 1)
@result2 = Operation.new.call(input: 1)
And

I can assure that

@result1.to_h == @result2.to_h