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.
Requiring a business transaction's steps to exist as independent operations directly addressable voa 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.
Invoking a call sheet requires two things: a container that contains available operations. And the actual call sheet that configures which operations to run, and the order.
The container groups arbitray operations, and could be a plain hash.
container = {
# name: -> operation
process: -> input { {name: input["name"], email: input["email"]} },
validate: -> input { input[:email].nil? ? raise(ValidationFailure, "not valid") : input },
persist: -> input { DB << input and true }
}
Each operation has to respond to #call(input)
.
Note that we use a hash here for simplicity, for larger apps you may like to consider something like dry-container.
To integrate your operations into a specific business transaction, you define a call sheet.
save_user = CallSheet(container: container) do
# adapter :operation_name
map :process
try :validate, catch: ValidationFailure
tee :persist
end
In a call sheet, you reference operations from your container using adapter methods.
When invoking the call sheet, these operations will be called in the specified order.
DB = []
save_user.call("name" => "Jane", "email" => "[email protected]")
# => Right({:name=>"Jane", :email=>"[email protected]"})
DB
# => [{:name=>"Jane", :email=>"[email protected]"}]
As a result, all three user operations :process
, :validate
and :persist
will be called. Their result is interpreted by the adapter that you specified in the call sheet.
A full invocation of a call sheet is called transaction.
The following adapters to plug operations into the sheet are available.
map
– any output is considered successful and returned asRight(output)
try
– the operation may raise an exception in an error case. This is caught and returned asLeft(exception)
. The output is otherwise returned asRight(output)
.tee
– the operation interacts with some external system and has no meaningful output. The original input is passed through and returned asRight(input)
.raw
orstep
– the operation already returns its ownEither
object, and needs no special handling.
Each transaction returns a result value wrapped in a Left
or Right
object. You can handle these different results (including errors arising from particular steps) with a match block:
save_user.call(name: "Jane", email: "[email protected]") 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
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" => "[email protected]"}
save_user.call(input, validate: ["doe.com"])
# => Right({:name=>"Jane", :email=>"[email protected]"})
save_user.call(input, validate: ["smith.com"])
# => Left("not allowed")
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" => "[email protected]"}
save_user.subscribe(persist: UserPersistListener)
save_user.call(input, validate: ["doe.com"])
NOTIFICATIONS
# => ["[email protected] 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)
.
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.
Add this line to your application’s Gemfile
:
gem "call_sheet"
Run bundle
to install the gem.
Bug reports and pull requests are welcome on GitHub.
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!
Copyright © 2015 Icelab. Call Sheet is free software, and may be redistributed under the terms specified in the license.