Skip to content

cdolan/effects

Repository files navigation

Effects

Call side effects like globals. Test them like dependencies.

Your app talks to Stripe, Redis, Slack, a logger. Four services, four stubbing mechanisms, four ways to fake them in tests. Effects gives you one pattern for all of them.

gem "effects"

How It Works

Declare effects -- the interfaces your code calls:

module Http
  extend Effects

  effect :get, :post
end

module Log
  extend Effects

  effect :info, :warn, :error
end

Write handlers -- the real implementations:

class NetHttp
  include Http

  def get(url, headers: {})            = Net::HTTP.get_response(URI(url))
  def post(url, body: "", headers: {}) = Net::HTTP.post(URI(url), body, headers)
end

class StdoutLog
  include Log

  def info(message)  = puts "[INFO]  #{message}"
  def warn(message)  = puts "[WARN]  #{message}"
  def error(message) = $stderr.puts "[ERROR] #{message}"
end

Run with handlers bound:

Effects.run NetHttp, StdoutLog do
  Log.info "Fetching..."
  
  response = Http.get("https://api.example.com/data")
  
  Log.info "Got #{response.code}"
end

Test with fakes -- no mocks, no stubs, no gems:

def test_charges_the_order
  http = FakeHttp.new

  with_effects http do
    OrderProcessor.call order

    assert_equal [:post, "https://payments.example.com/charge"], http.requests.last
  end
end

Three moving parts: an effect (the interface), a handler (the implementation), and Effects.run (the wiring). That's it.

Wiring It Up

Bind handlers at the boundary -- wherever your application code starts.

# Rack middleware (Rails, Sinatra, Roda)
class EffectsMiddleware
  def initialize(app) = @app = app

  def call(env)
    Effects.run NetHttp, RailsLog, RedisCache, SlackNotifier { @app.call(env) }
  end
end

# Sidekiq
Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    chain.add Class.new {
      def call(worker, job, queue)
        Effects.run NetHttp, RailsLog, RedisCache, SlackNotifier { yield }
      end
    }
  end
end

Every request and every job gets its own handler scope. When the block ends, handlers are gone.

Testing

Write fake handlers that record what happened:

class FakeHttp
  include Http

  attr_reader :requests

  def initialize    = @requests = []
  def get(url, **)  = (@requests << [:get, url]; {})
  def post(url, **) = (@requests << [:post, url]; {})
end

Configure defaults once -- they apply to every test:

require "effects/test_helpers/minitest"

Effects::MinitestHelpers.defaults = [NullLog, MemoryCache, NullNotifier]

class Minitest::Test
  include Effects::MinitestHelpers
end

Then test. Handlers you pass to with_effects override defaults for the same effect. Everything else comes from defaults:

def test_processes_order
  with_effects(FakeHttp.new) do
    # NullLog, MemoryCache, NullNotifier provided automatically
    result = OrderProcessor.call(order)

    assert_equal "paid", result.status
  end
end

def test_payment_failure
  with_effects FailingHttp.new do
    assert_raises(PaymentError) { OrderProcessor.call(order) }
  end
end

RSpec works the same way -- require "effects/test_helpers/rspec" and configure Effects::RSpecHelpers instead.

Not Globals

Scopes

Handlers exist only inside an Effects.run block. Block ends, handlers gone.

Isolation

Handler stacks are fiber-local. Concurrent requests under Puma can't interfere.

Validation

Missing handlers fail at run time with a clear error, not a NoMethodError ten frames deep.

Handlers also carry state. Tag every log line with the current request, without threading a logger through every call:

class RequestLog
  include Log

  def initialize(request_id:, user:)
    @prefix = "[req=#{request_id} user=#{user.id}]"
  end

  def info(message) = Rails.logger.info("#{@prefix} #{message}")
end

Effects.run RequestLog.new(request_id: env["X-Request-Id"], user: current_user), NetHttp do
  OrderProcessor.call order
  
  Log.info "Every Log.info in this request carries the request ID. No shared state."
end

Other Features

  • Nested scoping -- inner Effects.run blocks shadow outer handlers; cleanup is guaranteed even on exceptions
  • Parallel execution -- Effects.parallel runs lambdas in threads sharing the parent's handler context
  • Composition -- .with prepends modules onto handlers for cross-cutting concerns (caching, retries, timeouts) without modifying them
  • Multi-effect handlers -- one class can implement multiple effect interfaces
  • Introspection -- Effects.handled?, Effects.current, Effects.handlers for runtime inspection
  • Timeouts -- per-call via Effects.with_timeout or composable via handler.with(Timeouts.new(5))

Limitations: One-shot only -- no backtracking or nondeterminism. Fiber overhead is negligible for typical side effects (HTTP, database, logging) but not suited for hot inner loops doing millions of operations per second.

AI Integration

Every external capability is already behind a handler interface. An LLM is just a new handler. Algebraic effects separate description from interpretation -- swapping the interpreter is a one-line change.

Minimal code changes. No rewrites. No new frameworks.

# Before: hand-crafted, artisanal shipping logic
class ShippingCalculator
  include Pricing

  def estimate(package)
    weight_rate    = package.weight_lbs * 0.45
    zone_adj       = ZONE_MULTIPLIERS.fetch(package.destination_zone, 1.0)
    fuel_surcharge = weight_rate * FuelIndex.current_rate

    (weight_rate * zone_adj + fuel_surcharge).round(2)
  end
end

# After: AI-first pricing intelligence
class ClaudePricing
  include Pricing

  def estimate(package)
    response = Anthropic::Client.new.messages.create(
      model: "claude-sonnet-4-20250514",
      max_tokens: 64,
      messages: [{
        role: "user",
        content: "How much should it cost to ship a #{package.weight_lbs}lb package " \
                 "to zone #{package.destination_zone}? Just the number, in USD."
      }]
    )

    response.content.first.text.gsub(/[^0-9.]/, "").to_f
  end
end
# Before
Effects.run ShippingCalculator.new, StdoutLog do
  OrderProcessor.call(order)
end

# After: AI-powered logistics optimization
Effects.run ClaudePricing.new, StdoutLog do
  OrderProcessor.call(order)
end

License

The gem is available as open source under the terms of the MIT License.

About

Algebraic Effects for Ruby

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages