EasyOp Docs Operations

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

SymbolResolves to
:booleanTrueClass | FalseClass
:stringString
:integerInteger
:floatFloat
:symbolSymbol
:anyany 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:

  1. Calls PluginModule.install(self, **options) — the plugin prepends/extends the class
  2. 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
EasyOp v0.1.0 · MIT License · GitHub