mikey.bike . archive . other writings . lists . about
At work, we make heavy use of the dry-monads gem,
which gives us result objects (either Success or Failure) and
methods to bind them together, and if you squint it looks a lot like
Haskell’s do notation. If you squint too hard, though, you might
forget you’re in Ruby, where – despite the “principle of least
surprise” – side effects are hiding all over the place. Here’s one
way that can bite you if you aren’t careful.
Let’s say you have a couple of methods, each of which returns some result.
def mark_user_last_posted(user)
if user.update(last_posted_at: Time.current)
Success(user)
else
Failure(user.errors)
end
end
def create_post(user, title)
post = user.posts.build(title:)
if post.save
Success(post)
else
Failure(post.errors)
end
endAnd now, within a Dry::Monads::Do-wrapped method, you compose these
together, like so:
include Dry::Monads::Do.for(:run)
def run(user, title)
user = yield mark_user_last_posted(user)
post = yield create_post_for_user(user, title)
Success(post)
end(Apologies for the silliness of this example.) You are doing two
things that might fail: mutating the user by updating their
last_posted_at attribute, and creating a new post object. If
either fail, a Failure that wraps errors is returned; otherwise, the
newly-created post is returned wrapped in a Success.
The Dry::Monads::Do.for(:run) decorates your run method with
machinery handles the results of the yield calls by either
unwrapping Success values, or returning a Failure if it gets
one. It’s a nice, readable way to compose monadic methods, and it also
has an important feature.
You think about it for a second and realize this code has a problem:
it’s possible for this method to succeed in marking the user as having
posted, but fail in creating the new post. Luckily, Dry::Monads::Do
supports wrapping everything in a transaction:
def run(user, title)
user.transaction do
user = yield mark_user_last_posted(user)
post = yield create_post_for_user(user, title)
Success(post)
end
endAny Failure returned within a yield triggers a rollback; you now
have the transactional semantics you want. How does this work? You can
see the special do machinery defined here. Here’s the
relevant bit:
module Dry::Monads::Do::Mixin
def bind(monads)
monads = Do.coerce_to_monad(Array(monads))
unwrapped = monads.map do |result|
monad = result.to_monad
monad.or { Do.halt(monad) }.value!
end
monads.size == 1 ? unwrapped[0] : unwrapped
end
endIf you remove all the array-coercing shenanigans, you can simplify this to the core logic:
def bind(monad)
monad.or { Do.halt(monad) }.value!
endDo.halt raises an exception – this is key – which is caught
higher up in the method wrapper that Do generates. That exception will
first blow through the transaction block, triggering a rollback.
Great! Everything’s working. You git add your code and take once
last look before you commit. As you do, some deep memory surfaces –
this code looks familiar.
def run(user, title)
user.transaction do
user = yield mark_user_last_posted(user)
post = yield create_post_for_user(user, title)
Success(post)
end
endYou squint.
a = yield run_a
b = yield run_b(a)
Success(b)In Haskell, this looks like:
a <- run_a
b <- run_b a
pure bOf course, you think. It’s a call to bind, I know this.
An indelible feature of monads in Haskell are that monads have
laws. The identity law tells you that given some monad
value m, you can rely on the following equality:
val <- m
pure m
-- can be rewritten as just:
mWith some satisfaction (“I knew learning Haskell would finally come in handy!”), you rewrite your method:
def run(user, title)
user.transaction do
user = yield mark_user_last_posted(user)
create_post_for_user(user, title)
end
endThis doesn’t actually work, and you just broke transactional
safety. If create_post_for_user fails, the transaction still
commits and the user is marked as having posted.
Why is this? The problem is that yield was doing work here in
producing side effects that caused the rollback. Pure Failure
objects don’t cause exceptions, and the transaction happily commits.
Going back to our monad laws, dry-rb breaks the identity law by
producing a side effect in the bind method when using Do. In other
words, the identity equation does not hold:
m >>= \val -> pure val ≡ mThe two sides are not equal because the expression on the right has
removed a bind call (>>=), and because bind has side effects, the
semantics are different.
In general, any pure return of a Failure result without using
yield will skip the exception side effect. So, for instance, this
(somewhat silly) code is also broken:
def run(user, title)
user.transaction do
user = yield mark_user_last_posted(user)
return Failure("user is not verified") unless user.verified?
post = yield create_post_for_user(user, title)
Success(post)
end
endI’m not sure why you would write the logic this way, but the return
guard in the middle of the transaction won’t trigger the rollback, for
the same reason: it’s just a pure Failure value. Instead, you’d need
to write it using yield:
def run(user, title)
user.transaction do
user = yield mark_user_last_posted(user)
yield Failure("user is not verified") unless user.verified?
post = yield create_post_for_user(user, title)
Success(post)
end
endDon’t assume that Ruby has laws.