Operations
An operation is the fundamental unit of business logic in EasyOp. Include Easyop::Operation in any Ruby class and define a call method.
Minimal Operation
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
result = DoubleNumber.call(number: 21)
result.success? # => true
result.result # => 42
Including Easyop::Operation adds these to your class automatically:
- ClassMethods:
.call,.call!,.plugin,._registered_plugins - Easyop::Hooks:
before,after,around - Easyop::Rescuable:
rescue_from - Easyop::Skip:
skip_if - Easyop::Schema:
params/inputs,result/outputs
Hooks
Lifecycle callbacks run around the call method.
before / after
class CreateUser
include Easyop::Operation
before :validate_inputs # symbol — runs a named method
before { ctx.email = ctx.email.to_s.strip.downcase } # block
after :notify_admin # always runs — even after failure
def call
ctx.user = User.create!(ctx.slice(:email, :name))
end
private
def validate_inputs
ctx.fail!(error: "email is required") if ctx.email.to_s.empty?
end
def notify_admin
AdminMailer.new_user(ctx.user).deliver_later if ctx.success?
end
end
around
class TrackedOperation
include Easyop::Operation
around :with_timing # method-style: use yield
around { |inner| Sentry.with_scope { inner.call } } # block-style: call inner.call
def call
ctx.result = do_work
end
private
def with_timing
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield # continues the chain
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
Rails.logger.info "#{self.class.name} took #{ms}ms"
end
end
Hook execution order
# Given:
class MyOp
include Easyop::Operation
before :b1
before :b2
after :a1
around :o1
around :o2
def call; end
end
# Execution order:
# o1 starts
# o2 starts
# b1
# b2
# call
# a1 (in ensure — always runs)
# o2 ends
# o1 ends
Hook inheritance
class ApplicationOperation
include Easyop::Operation
before :set_timestamps
private
def set_timestamps; ctx.started_at = Time.current; end
end
class CreateUser < ApplicationOperation
before :validate_email # runs AFTER parent's before hooks
def call; ctx.user = User.create!(email: ctx.email); end
private
def validate_email; ctx.fail!(error: "invalid email") unless ctx.email&.include?("@"); end
end
rescue_from
Handle exceptions without littering call with begin/rescue blocks. Handlers run inside the operation instance and have access to ctx.
class ParseJson
include Easyop::Operation
rescue_from JSON::ParserError do |e|
ctx.fail!(error: "Invalid JSON: #{e.message.lines.first.strip}")
end
def call
ctx.parsed = JSON.parse(ctx.raw)
end
end
with: method reference
class ImportData
include Easyop::Operation
rescue_from CSV::MalformedCSVError, with: :handle_bad_csv
rescue_from ActiveRecord::RecordInvalid do |e|
ctx.fail!(error: e.message, errors: e.record.errors.to_h)
end
rescue_from StandardError, with: :handle_unexpected
def call
# ...
end
private
def handle_bad_csv(e)
ctx.fail!(error: "CSV is malformed: #{e.message}")
end
def handle_unexpected(e)
Sentry.capture_exception(e)
ctx.fail!(error: "An unexpected error occurred")
end
end
Handler priority
- Handlers are checked in child-first, then parent order
- Within a class, handlers are checked in definition order (first match wins)
- A child class handler for the same exception class takes priority over the parent's
class Base
include Easyop::Operation
rescue_from StandardError do |_e|
ctx.fail!(error: "base: standard error")
end
end
class Child < Base
rescue_from RuntimeError do |e|
ctx.fail!(error: "child: #{e.message}") # takes priority for RuntimeError
end
def call; raise RuntimeError, "boom"; end
end
Child.call.error # => "child: boom"
Typed Schema
Declare expected inputs and outputs. Validation runs as hooks — params before call, result after.
params / inputs
class RegisterUser
include Easyop::Operation
params do # validated before call
required :email, String
required :age, Integer
optional :plan, String, default: "free"
optional :admin, :boolean, default: false
optional :note, String # optional with no default
end
def call
ctx.user = User.create!(ctx.slice(:email, :age, :plan))
end
end
# Identical — inputs is an alias for params:
class RegisterUser
inputs do
required :email, String
end
end
result / outputs
class RegisterUser
include Easyop::Operation
result do # validated after call (only on success)
required :user, User
optional :token, String
end
def call
ctx.user = User.create!(ctx.slice(:email))
end
end
# outputs is an alias for result:
outputs do
required :record, ActiveRecord::Base
end
Type shorthands
| Symbol | Resolves to |
|---|---|
:boolean | TrueClass | FalseClass |
:string | String |
:integer | Integer |
:float | Float |
:symbol | Symbol |
:any | any value (no type check) |
Pass any Ruby class directly: required :user, User or required :base, ActiveRecord::Base.
strict_types
Easyop.configure { |c| c.strict_types = true }
# true → ctx.fail! on type mismatch
# false → emit a warning to $stderr (default)
op = Class.new do
include Easyop::Operation
params { required :age, Integer }
def call; end
end
op.call(age: "30").failure? # => true (strict mode)
op.call(age: "30").failure? # => false, but warns (permissive mode)
Inheritance
Subclasses inherit all hooks, rescue handlers, and schema from parent classes.
class ApplicationOperation
include Easyop::Operation
rescue_from ActiveRecord::RecordNotFound do |e|
ctx.fail!(error: "#{e.model} not found")
end
rescue_from ActiveRecord::RecordInvalid do |e|
ctx.fail!(
error: e.message,
errors: e.record.errors.to_h
)
end
rescue_from StandardError do |e|
Sentry.capture_exception(e)
ctx.fail!(error: "An unexpected error occurred")
end
end
class UpdateUser < ApplicationOperation
def call
user = User.find(ctx.user_id) # RecordNotFound handled by parent
user.update!(ctx.slice(:name, :email)) # RecordInvalid handled by parent
ctx.user = user
end
end
result = UpdateUser.call(user_id: 999)
result.failure? # => true
result.error # => "User not found"
Plugin DSL
Activate plugins on an operation class. Subclasses inherit all installed plugins.
class ApplicationOperation
include Easyop::Operation
plugin Easyop::Plugins::Instrumentation
plugin Easyop::Plugins::Recording, model: OperationLog
plugin Easyop::Plugins::Async, queue: "operations"
plugin Easyop::Plugins::Transactional
end
# Child classes inherit all four plugins automatically:
class CreateUser < ApplicationOperation
def call
ctx.user = User.create!(email: ctx.email)
end
end
Each plugin call:
- Calls
PluginModule.install(self, **options)— the plugin prepends/extends the class - Appends an entry to
_registered_plugins
_registered_plugins
Inspect which plugins are installed on a class:
ApplicationOperation._registered_plugins
# => [
# { plugin: Easyop::Plugins::Instrumentation, options: {} },
# { plugin: Easyop::Plugins::Recording, options: { model: OperationLog } },
# { plugin: Easyop::Plugins::Async, options: { queue: "operations" } },
# { plugin: Easyop::Plugins::Transactional, options: {} }
# ]
# Each subclass has its own list (only plugins registered on that class):
class FastOp < ApplicationOperation
plugin MyTimingPlugin
end
FastOp._registered_plugins
# => [{ plugin: MyTimingPlugin, options: {} }]
# (parent's plugins not listed — they are inherited via the MRO)
Rollback
Define a rollback method to undo side effects when a flow fails. Rollback is called by Easyop::Flow — it has no effect when calling an operation directly.
class ChargePayment
include Easyop::Operation
def call
ctx.charge = Stripe::Charge.create(amount: ctx.total, source: ctx.token)
end
def rollback
Stripe::Refund.create(charge: ctx.charge.id) if ctx.charge
end
end