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"Declare effects -- the interfaces your code calls:
module Http
extend Effects
effect :get, :post
end
module Log
extend Effects
effect :info, :warn, :error
endWrite 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}"
endRun with handlers bound:
Effects.run NetHttp, StdoutLog do
Log.info "Fetching..."
response = Http.get("https://api.example.com/data")
Log.info "Got #{response.code}"
endTest 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
endThree moving parts: an effect (the interface), a handler (the
implementation), and Effects.run (the wiring). That's it.
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
endEvery request and every job gets its own handler scope. When the block ends, handlers are gone.
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]; {})
endConfigure defaults once -- they apply to every test:
require "effects/test_helpers/minitest"
Effects::MinitestHelpers.defaults = [NullLog, MemoryCache, NullNotifier]
class Minitest::Test
include Effects::MinitestHelpers
endThen 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
endRSpec works the same way -- require "effects/test_helpers/rspec" and
configure Effects::RSpecHelpers instead.
Handlers exist only inside an Effects.run block. Block ends, handlers gone.
Handler stacks are fiber-local. Concurrent requests under Puma can't interfere.
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- Nested scoping -- inner
Effects.runblocks shadow outer handlers; cleanup is guaranteed even on exceptions - Parallel execution --
Effects.parallelruns lambdas in threads sharing the parent's handler context - Composition --
.withprepends 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.handlersfor runtime inspection - Timeouts -- per-call via
Effects.with_timeoutor composable viahandler.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.
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)
endThe gem is available as open source under the terms of the MIT License.