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:
moduleHttpextendEffectseffect:get,:postendmoduleLogextendEffectseffect:info,:warn,:errorendWrite handlers -- the real implementations:
classNetHttpincludeHttpdefget(url,headers: {})=Net::HTTP.get_response(URI(url))defpost(url,body: "",headers: {})=Net::HTTP.post(URI(url),body,headers)endclassStdoutLogincludeLogdefinfo(message)=puts"[INFO] #{message}"defwarn(message)=puts"[WARN] #{message}"deferror(message)= $stderr.puts"[ERROR] #{message}"endRun with handlers bound:
Effects.runNetHttp,StdoutLogdoLog.info"Fetching..."response=Http.get("https://api.example.com/data")Log.info"Got #{response.code}"endTest with fakes -- no mocks, no stubs, no gems:
deftest_charges_the_orderhttp=FakeHttp.newwith_effectshttpdoOrderProcessor.callorderassert_equal[:post,"https://payments.example.com/charge"],http.requests.lastendendThree 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)classEffectsMiddlewaredefinitialize(app)=@app=appdefcall(env)Effects.runNetHttp,RailsLog,RedisCache,SlackNotifier{@app.call(env)}endend# SidekiqSidekiq.configure_serverdo |config|
config.server_middlewaredo |chain|
chain.addClass.new{defcall(worker,job,queue)Effects.runNetHttp,RailsLog,RedisCache,SlackNotifier{yield}end}endendEvery request and every job gets its own handler scope. When the block ends, handlers are gone.
Write fake handlers that record what happened:
classFakeHttpincludeHttpattr_reader:requestsdefinitialize=@requests=[]defget(url, **)=(@requests << [:get,url];{})defpost(url, **)=(@requests << [:post,url];{})endConfigure defaults once -- they apply to every test:
require"effects/test_helpers/minitest"Effects::MinitestHelpers.defaults=[NullLog,MemoryCache,NullNotifier]classMinitest::TestincludeEffects::MinitestHelpersendThen test. Handlers you pass to with_effects override defaults for the
same effect. Everything else comes from defaults:
deftest_processes_orderwith_effects(FakeHttp.new)do# NullLog, MemoryCache, NullNotifier provided automaticallyresult=OrderProcessor.call(order)assert_equal"paid",result.statusendenddeftest_payment_failurewith_effectsFailingHttp.newdoassert_raises(PaymentError){OrderProcessor.call(order)}endendRSpec 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:
classRequestLogincludeLogdefinitialize(request_id:,user:)@prefix="[req=#{request_id} user=#{user.id}]"enddefinfo(message)=Rails.logger.info("#{@prefix}#{message}")endEffects.runRequestLog.new(request_id: env["X-Request-Id"],user: current_user),NetHttpdoOrderProcessor.callorderLog.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 logicclassShippingCalculatorincludePricingdefestimate(package)weight_rate=package.weight_lbs * 0.45zone_adj=ZONE_MULTIPLIERS.fetch(package.destination_zone,1.0)fuel_surcharge=weight_rate * FuelIndex.current_rate(weight_rate * zone_adj + fuel_surcharge).round(2)endend# After: AI-first pricing intelligenceclassClaudePricingincludePricingdefestimate(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_fendend# BeforeEffects.runShippingCalculator.new,StdoutLogdoOrderProcessor.call(order)end# After: AI-powered logistics optimizationEffects.runClaudePricing.new,StdoutLogdoOrderProcessor.call(order)endThe gem is available as open source under the terms of the MIT License.