Getting Started
Installation
Add EasyOp to your Gemfile:
gem "easyop"
Then run:
bundle install
EasyOp has no required runtime dependencies — it works in plain Ruby, Sinatra, or Rails.
Your First Operation
An operation is any Ruby class that includes Easyop::Operation and defines a call method.
class DoubleNumber
include Easyop::Operation
def call
ctx.fail!(error: "input must be a number") unless ctx.number.is_a?(Numeric)
ctx.result = ctx.number * 2
end
end
Call it and inspect the result:
result = DoubleNumber.call(number: 21)
result.success? # => true
result.result # => 42
result = DoubleNumber.call(number: "oops")
result.failure? # => true
result.error # => "input must be a number"
Every operation:
- Receives input via
ctx— the shared data bag - Writes output back to
ctx - Calls
ctx.fail!to halt and mark failure - Returns
ctx— the result object
.call vs .call!
EasyOp provides two entry points:
| Method | On success | On ctx.fail! | On unhandled exception |
|---|---|---|---|
.call(attrs) |
Returns ctx | Returns ctx (ctx.failure? == true) |
Re-raises after setting ctx.error |
.call!(attrs) |
Returns ctx | Raises Easyop::Ctx::Failure |
Re-raises (unhandled) |
# Safe variant — check ctx.failure? afterwards
result = DoubleNumber.call(number: "bad")
result.failure? # => true
result.error # => "input must be a number"
# Bang variant — rescue the exception
begin
result = DoubleNumber.call!(number: "bad")
rescue Easyop::Ctx::Failure => e
e.ctx.error # => "input must be a number"
e.message # => "Operation failed: input must be a number"
end
Tip
Use
.call in controllers where you want to handle failure inline. Use .call! in service objects or pipelines where the caller is responsible for error handling.
Configuration
Configure EasyOp once, typically in an initializer:
# config/initializers/easyop.rb
Easyop.configure do |c|
# Schema type validation mode:
# false (default) — emit a warning on type mismatch, continue
# true — call ctx.fail! on type mismatch
c.strict_types = false
# Type adapter for schema validation:
# :native (default) — Ruby is_a? checks
# :none — skip type checking entirely
c.type_adapter = :native
end
Resetting Configuration
In tests, reset configuration before each example to avoid state leakage:
# spec/spec_helper.rb
RSpec.configure do |config|
config.before(:each) do
Easyop.reset_config!
end
end
Easyop.reset_config! replaces the current config with a fresh Configuration instance using all defaults.
Adding Hooks
Use before, after, and around to add lifecycle callbacks:
class NormalizeEmail
include Easyop::Operation
before :strip_whitespace
after :log_result
around :with_timing
def call
ctx.normalized = ctx.email.downcase
end
private
def strip_whitespace
ctx.email = ctx.email.to_s.strip
end
def log_result
Rails.logger.info "Normalized: #{ctx.normalized}" if ctx.success?
end
def with_timing
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
Rails.logger.info "NormalizeEmail took #{ms}ms"
end
end
result = NormalizeEmail.call(email: " ALICE@EXAMPLE.COM ")
result.normalized # => "alice@example.com"
Typed Schema
Declare expected inputs and outputs with the params / result DSL:
class RegisterUser
include Easyop::Operation
params do
required :email, String
required :age, Integer
optional :plan, String, default: "free"
optional :admin, :boolean, default: false
end
result do
required :user, User
end
def call
ctx.user = User.create!(ctx.slice(:email, :age, :plan))
end
end
result = RegisterUser.call(email: "alice@example.com", age: 30)
result.success? # => true
result.plan # => "free" (default applied)
result = RegisterUser.call(email: "bob@example.com")
result.failure? # => true
result.error # => "Missing required params field: age"
Next Steps
- Ctx API Reference — every method on the context object
- Operations — hooks, rescue_from, schemas, inheritance, plugins
- Flows — composing operations with rollback support
- Testing Guide — RSpec patterns for operations and flows
- Rails Integration — controller patterns and plugin setup