[go: up one dir, main page]

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:

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).

octocatEdit on GitHub