Call Sheet Join the chat at https://gitter.im/icelab/call_sheet

Gem Version Code Climate API Documentation Coverage

Call Sheet is a business transaction DSL. It provides a simple way to define a complex business transaction that includes processing by many different objects. It makes error handling a primary concern by using a “Railway Oriented Programming” approach for capturing and returning errors from any step in the transaction.

Call Sheet is based on the following ideas, drawn mostly from Transflow:

  • A business transaction is a series of operations where each can fail and stop processing.
  • A business transaction resolves its dependencies using an external container object and it doesn’t know any details about the individual operation objects except their identifiers.
  • A business transaction can describe its steps on an abstract level without being coupled to any details about how individual operations work.
  • A business transaction doesn’t have any state.
  • Each operation shouldn’t accumulate state, instead it should receive an input and return an output without causing any side-effects.
  • The only interface of a an operation is #call(input).
  • Each operation provides a meaningful functionality and can be reused.
  • Errors in any operation can be easily caught and handled as part of the normal application flow.

Why?

Requiring a business transaction’s steps to exist as independent operations directly addressable via a container means that they can be tested in isolation and easily reused throughout your application. Following from this, keeping the business transaction to a series of high-level, declarative steps ensures that it’s easy to understand at a glance.

The output of each step is wrapped in a Kleisli Either object (Right for success or Left for failure). This allows the steps to be chained together and ensures that processing stops in the case of a failure. Returning an Either from the overall transaction also allows for error handling to remain a primary concern without it getting in the way of tidy, straightforward operation logic. Wrapping the step output also means that you can work with a wide variety of operations within your application – they don’t need to return an Either already.

Usage

Container

All you need to use Call Sheet is a container to hold your application’s operations. Each operation must respond to #call(input).

The operations will be resolved from the container via #[]. For our examples, we’ll use a plain hash:

container = {
  process:  -> input { {name: input["name"], email: input["email"]} },
  validate: -> input { input[:email].nil? ? raise(ValidationFailure, "not valid") : input },
  persist:  -> input { DB << input and true }
}

For larger apps, you may like to consider something like dry-container.

Defining a transaction

Define a transaction to bring your opererations together:

save_user = CallSheet(container: container) do
  map :process
  try :validate, catch: ValidationFailure
  tee :persist
end

Operations are formed into steps using step adapters. Step adapters wrap the output of your operations to make them easy to integrate into a transaction. The following adapters are available:

  • step – the operation already returns an Either object (Right(output) for success and Left(output) for failure), and needs no special handling.
  • map – any output is considered successful and returned as Right(output)
  • try – the operation may raise an exception in an error case. This is caught and returned as Left(exception). The output is otherwise returned as Right(output).
  • tee – the operation interacts with some external system and has no meaningful output. The original input is passed through and returned as Right(input).

Calling a transaction

Calling a transaction will run its operations in their specified order, with the output of each operation becoming the input for the next.

DB = []

save_user.call("name" => "Jane", "email" => "jane@doe.com")
# => Right({:name=>"Jane", :email=>"jane@doe.com"})

DB
# => [{:name=>"Jane", :email=>"jane@doe.com"}]

Each transaction returns a result value wrapped in a Left or Right object (based on the output of its final step). You can handle these results (including errors arising from particular steps) with a match block:

save_user.call(name: "Jane", email: "jane@doe.com") do |m|
  m.success do
    puts "Succeeded!"
  end

  m.failure do |f|
    f.on :validate do |errors|
      # In a more realistic example, you’d loop through a list of messages in `errors`.
      puts "Couldn’t save this user. Please provide an email address."
    end

    f.otherwise do |error|
      puts "Couldn’t save this user."
    end
  end
end

Passing additional step arguments

Additional arguments for step operations can be passed at the time of calling your transaction. Provide these arguments as an array, and they’ll be splatted into the front of the operation’s arguments. This means that transactions can effectively support operations with any sort of #call(*args, input) interface.

DB = []

container = {
  process:  -> input { {name: input["name"], email: input["email"]} },
  validate: -> allowed, input { input[:email].include?(allowed) ? raise(ValidationFailure, "not allowed") : input },
  persist:  -> input { DB << input and true }
}

save_user = CallSheet(container: container) do
  map :process
  try :validate, catch: ValidationFailure
  tee :persist
end

input = {"name" => "Jane", "email" => "jane@doe.com"}
save_user.call(input, validate: ["doe.com"])
# => Right({:name=>"Jane", :email=>"jane@doe.com"})

save_user.call(input, validate: ["smith.com"])
# => Left("not allowed")

Subscribing to step notifications

As well as pattern matching on the final transaction result, you can subscribe to individual steps and trigger specific behaviour based on their success or failure:

NOTIFICATIONS = []

module UserPersistListener
  extend self

  def persist_success(user)
    NOTIFICATIONS << "#{user[:email]} persisted"
  end

  def persist_failure(user)
    NOTIFICATIONS << "#{user[:email]} failed to persist"
  end
end


input = {"name" => "Jane", "email" => "jane@doe.com"}

save_user.subscribe(persist: UserPersistListener)
save_user.call(input, validate: ["doe.com"])

NOTIFICATIONS
# => ["jane@doe.com persisted"]

This pub/sub mechanism is provided by the Wisper gem. You can subscribe to specific steps using the #subscribe(step_name: listener) API, or subscribe to all steps via #subscribe(listener).

Working with a larger container

In practice, your container won’t be a trivial collection of generically named operations. You can keep your transaction step names simple by using the with: option to provide the identifiers for the operations within your container:

save_user = CallSheet(container: large_whole_app_container) do
  map :process, with: "attributes.user"
  try :validate, with: "validations.user", catch: ValidationFailure
  tee :persist, with: "persistance.commands.update_user"
end

A raw step (also aliased as step) can be used if the operation in your container already returns an Either and therefore doesn’t need any special handling.

Installation

Add this line to your application’s Gemfile:

gem "call_sheet"

Run bundle to install the gem.

Contributing

Bug reports and pull requests are welcome on GitHub.

Credits

Call Sheet is developed and maintained by Icelab.

Call Sheet’s error handling is based on Scott Wlaschin’s Railway Oriented Programming, found via Zohaib Rauf’s Railway Oriented Programming in Elixir blog post. Call Sheet’s behavior as a business transaction library draws heavy inspiration from Piotr Solnica’s Transflow and Gilbert B Garza’s Solid Use Case. Josep M. Bach’s Kleisli gem makes functional programming patterns in Ruby accessible and fun. Thank you all!

License

Copyright © 2015 Icelab. Call Sheet is free software, and may be redistributed under the terms specified in the license.