Skip to content

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:

moduleHttpextendEffectseffect:get,:postendmoduleLogextendEffectseffect:info,:warn,:errorend

Write 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}"end

Run with handlers bound:

Effects.runNetHttp,StdoutLogdoLog.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:

deftest_charges_the_orderhttp=FakeHttp.newwith_effectshttpdoOrderProcessor.callorderassert_equal[:post,"https://payments.example.com/charge"],http.requests.lastendend

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)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}endend

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:

classFakeHttpincludeHttpattr_reader:requestsdefinitialize=@requests=[]defget(url, **)=(@requests << [:get,url];{})defpost(url, **)=(@requests << [:post,url];{})end

Configure defaults once -- they apply to every test:

require"effects/test_helpers/minitest"Effects::MinitestHelpers.defaults=[NullLog,MemoryCache,NullNotifier]classMinitest::TestincludeEffects::MinitestHelpersend

Then 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)}endend

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:

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

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

License

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

About

Algebraic Effects for Ruby

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages