Introduction
dry-effects
is a practical, production-oriented implementation of algebraic effects in Ruby.
Why?
Algebraic effects are a powerful tool for writing composable and testable code in a safe way. Fundamentally, any effect consists of two parts: introduction (throwing effect) and elimination (handling effect with an effect provider). One of the many things you can do with them is sharing state:
require 'dry/effects'
class CounterMiddleware
# This adds a `counter` effect provider. It will handle (eliminate) effects
include Dry::Effects::Handler.State(:counter)
def initialize(app)
@app = app
end
def call(env)
# Calling `with_counter` makes the value available anywhere in `@app.call`
counter, response = with_counter(0) do
@app.(env)
end
# Once processing is complete, the result value
# will be stored in `counter`
response
end
end
### Somewhere deep in your app
class CreatePost
# Adds counter accessor (by introducing state effects)
include Dry::Effects.State(:counter)
def call(values)
# Value is passed from middleware
self.counter += 1
# ...
end
end
CreatePost#call
can only be called when there's with_counter
somewhere in the stack. If you want to test CreatePost
separately, you'll need to use with_counter
in tests too:
require 'dry/effects'
require 'posting_app/create_post'
RSpec.describe CreatePost do
include Dry::Effects::Handler::State(:counter)
subject(:create_post) { described_class.new }
it 'updates the counter' do
counter, post = with_counter(0) { create_post.(post_values) }
expect(counter).to be(1)
end
end
Any introduced effect must have a handler. If no handler found you'll see an error:
CreatePost.new.({})
# => Dry::Effects::Errors::MissingStateError (Value of +counter+ is not set, you need to provide value with an effect handler)
In a statically typed programming language with support for algebraic effects you won't be able to run code without providing all required handlers, it'd be a type error.
It may remind you using global state, but it's not actually global. It should instead be called "goto on steroids" or "goto made unharmful."
Cmp
State sharing is one of many effects already supported; another example is comparative execution. Imagine you test a new feature that ideally shouldn't affect application responses.
require 'dry/effects'
class TestNewFeatureMiddleware
# `as:` renames handler method
include Dry::Effects::Handler.Cmp(:feature, as: :test_feature)
def initialize(app)
@app = app
end
def call(env)
without_feature, with_feature = test_feature do
@app.(env)
end
if with_feature != without_feature
# something is different!
end
without_feature
end
end
### Somewhere deep in your app
class PostView
include Dry::Effects.Cmp(:feature)
def call
if feature?
# do render with feature
else
# do render without feature
end
end
end
The Cmp
provider will run your code twice so that you can compare the results and detect differences.
Composition
So far effects haven't shown anything algebraic about themselves. Here comes composition. Any effect is composable with one another. Say we have code using both State
and Cmp
effects:
require 'dry/effects'
class GreetUser
include Dry::Effects.Cmp(:excitement)
include Dry::Effects.State(:greetings_given)
def call(name)
self.greetings_given += 1
if excitement?
"#{greetings_given}. Hello #{name}!"
else
"#{greetings_given}. Hello #{name}"
end
end
end
It's a simple piece of code that requires a single argument and two effect handlers to run:
class Context
include Dry::Effects::Handler.Cmp(:excitement, as: :test_excitement)
include Dry::Effects::Handler.State(:greetings_given)
def initialize
@greeting = GreetUser.new
end
def call(name)
test_excitement do
with_greetings_given(0) do
@greeting.(name)
end
end
end
end
Context.new.('Alice')
# => [[1, "1. Hello Alice"], [1, "1. Hello Alice!"]]
The result is two branches with excitement=false
and excitement=true
. Every variant has its state handler and hence returns another array with the number of greetings given and the greeting. However, neither our code nor algebraic effects restrict the order in which the effects are meant to be handled so let's swap the handlers:
class Context
# ...
def call(name)
with_greetings_given(0) do
test_excitement do
@greeting.(name)
end
end
end
end
Context.new.('Alice')
# => [2, ["1. Hello Alice", "2. Hello Alice!"]]
Now the same code returns a different result! Even more, it has a different shape (or type, if you will): ((Integer, String), (Integer, String))
vs. (Integer, (String, String))
!
Algebraic effects
Algebraic effects are relatively recent research describing a possible implementation of the effect system. An effect is some capability your code requires to be executed. It gives control over what your code does and helps a lot with testing without involving any magic like allow(Time).to receive(:now).and_return(@time_now)
. Instead, getting the current time is just another effect, as simple as that.
Algebraic effects lean towards functional programming enabling things like dependency injection, mutable state, obtaining the current time and random values in pure code. All that is done avoiding troubles accompanying monad stacks and monad transformers. Even things like JavaScript's async
/await
and Python's asyncio
can be generalized with algebraic effects.
If you're interested in the subject, there is a list of articles, papers, and videos, in no particular order:
- Algebraic Effects for the Rest of Us by Dan Abramov, an (unsophisticated) introduction for React/JavaScript developers.
- An Introduction to Algebraic Effects and Handlers is an approachable paper describing the semantics. Take a look if you want to know more on the subject.
- Algebraic Effects for Functional Programming is another paper by Microsoft Research.
- Asynchrony with Algebraic Effects intro given by Daan Leijen, the author of the previous paper and the Koka programming language created specifically for exploring algebraic effects.
- Do Be Do Be Do describes the Frank programming language with typed effects and ML-like syntax.
Goal of dry-effects
Despite different effects are compatible one with each other, libraries implementing them (not using them!) are not compatible out of the box. dry-effects
is aimed to be the standard implementation across dry-rb and rom-rb gems (and possibly others).