Do notation
Composing several monadic values can become tedious because you need to pass around unwrapped values in lambdas (aka blocks). Haskell was one of the first languages faced this problem. To work around it Haskell has a special syntax for combining monadic operations called the "do notation". If you're familiar with Scala it has for-comprehensions for a similar purpose. It is not possible to implement do in Ruby but it is possible to emulate it to some extent, i.e. achieve comparable usefulness.
What Do does is passing an unwrapping block to certain methods. The block tries to extract the underlying value from a monadic object and either short-circuits the execution (in case of a failure) or returns the unwrapped value back.
See the following example written using bind and fmap:
require 'dry/monads'
class CreateAccount
include Dry::Monads[:result]
def call(params)
validate(params).bind do |values|
create_account(values[:account]).bind do |account|
create_owner(account, values[:owner]).fmap do |owner|
[account, owner]
end
end
end
end
def validate(params)
# returns Success(values) or Failure(:invalid_data)
end
def create_account(account_values)
# returns Success(account) or Failure(:account_not_created)
end
def create_owner(account, owner_values)
# returns Success(owner) or Failure(:owner_not_created)
end
end
The more monadic steps you need to combine the harder it becomes, not to mention how difficult it can be to refactor code written in such way.
Embrace Do:
require 'dry/monads'
require 'dry/monads/do'
class CreateAccount
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call)
def call(params)
values = yield validate(params)
account = yield create_account(values[:account])
owner = yield create_owner(account, values[:owner])
Success([account, owner])
end
def validate(params)
# returns Success(values) or Failure(:invalid_data)
end
def create_account(account_values)
# returns Success(account) or Failure(:account_not_created)
end
def create_owner(account, owner_values)
# returns Success(owner) or Failure(:owner_not_created)
end
end
Both snippets do the same thing yet the second one is a lot easier to deal with. All what Do does here is prepending CreateAccount with a module which passes a block to CreateAccount#call.
yield
A little more on yield. It will accept a Result (remember that's either a Success or Failure object) and if the Result is a Success object, then yield will unpack it.
For example, in the above Do code snippet (repeated below for clarify), if create_account returns Success("account created") then the yield part will unpack the value of Success and simply return "account created"
account = yield create_account(values[:account])
It's worth mentioning that if create_account returns a Failure then yield won't unpack that but instead short circuit the execution.
That simple.
Transaction safety
Under the hood, Do uses exceptions to halt unsuccessful operations, this can be slower if you are dealing with unsuccessful paths a lot, but usually, this is not an issue. Check out this article for actual benchmarks.
One particular reason to use exceptions is the ability to make code transaction-friendly. In the example above, this piece of code is not atomic:
account = yield create_account(values[:account])
owner = yield create_owner(account, values[:owner])
Success[account, owner]
What if create_account succeeds and create_owner fails? This will leave your database in an inconsistent state. Let's wrap it with a transaction block:
repo.transaction do
account = yield create_account(values[:account])
owner = yield create_owner(account, values[:owner])
Success[account, owner]
end
Since yield internally uses exceptions to control the flow, the exception will be detected by the transaction call and the whole operation will be rolled back. No more garbage in your database, yay!
Limitations
Do only works with single-value monads, i.e. most of them. At the moment, there is no way to make it work with List, though.
Adding batteries
The Do::All module takes one step ahead, it tracks all new methods defined in the class and passes a block to every one of them. However, if you pass a block yourself then it takes precedence. This way, in most cases you can use Do::All instead of listing methods with Do.for(...):
require 'dry/monads'
class CreateAccount
# This will include Do::All by default
include Dry::Monads[:result, :do]
def call(account_params, owner_params)
repo.transaction do
account = yield create_account(account_params)
owner = yield create_owner(account, owner_params)
Success[account, owner]
end
end
def create_account(params)
values = yield validate_account(params)
account = repo.create_account(values)
Success(account)
end
def create_owner(account, params)
values = yield validate_owner(params)
owner = repo.create_owner(account, values)
Success(owner)
end
def validate_account(params)
# returns Success/Failure
end
def validate_owner(params)
# returns Success/Failure
end
end
Note that Do::All will not automatically pass a block to methods inherited from ancestors, such as included modules or a parent class.
Using Do methods in other contexts
You can use methods from the Do module directly (starting with 1.3):
require 'dry/monads/do'
require 'dry/monads/result'
# some random place in your code
Dry::Monads.Do.() do
user = Dry::Monads::Do.bind create_user
account = Dry::Monads::Do.bind create_account(user)
Dry::Monads::Success[user, account]
end
Or you can use extend:
require 'dry/monads'
class VeryComplexAndUglyCode
extend Dry::Monads::Do::Mixin
extend Dry::Monads[:result]
def self.create_something(result_value)
call do
extracted = bind result_value
processed = bind process(extracted)
Success(processed)
end
end
end
Do::All also works with class methods:
require 'dry/monads'
class SomeClassLevelLogic
extend Dry::Monads[:result, :do]
def self.call
x = yield Success(5)
y = yield Success(20)
Success(x * y)
end
end
SomeClassLevelLogic.() # => Success(100)