EasyOp Docs Flows

Flows

Easyop::Flow runs a sequence of operations sharing one ctx. Failure in any step halts the chain and triggers rollback of completed steps in reverse order.

Basic Flow

class ProcessCheckout
  include Easyop::Flow

  flow ValidateCart,
       ApplyCoupon,
       ChargePayment,
       CreateOrder,
       SendConfirmation
end

result = ProcessCheckout.call(user: current_user, cart: current_cart)
result.success?  # => true
result.order     # => #<Order id=42>

How Execution Works

For each step in the flow, EasyOp:

  1. Checks skip? — if skip_if is declared and truthy, the step is skipped entirely (not added to the rollback list)
  2. Checks inline lambda guards (if a ->(ctx){} precedes the step)
  3. Creates an instance of the step class
  4. Calls instance._easyop_run(ctx, raise_on_failure: true) — the step runs with full hook and rescue support
  5. On success: calls ctx.called!(instance) to register for rollback
  6. On failure (Ctx::Failure raised): stops the chain, calls ctx.rollback!
# Simplified execution model:
#
# ProcessOrder.call(attrs)
#   └─ new._easyop_run(ctx, raise_on_failure: false)
#         └─ _run_safe
#               └─ prepare { call }  ← Flow::call
#                     ├─ step1: skip? → instantiate → _easyop_run → called!
#                     ├─ step2: skip? → instantiate → _easyop_run → called!
#                     ├─ step3: fails → raises Ctx::Failure
#                     └─ rescue: ctx.rollback! → step2.rollback, step1.rollback

Rollback

Each step can define a rollback method. On failure, completed steps are rolled back in reverse order. Errors inside individual rollbacks are swallowed so all rollbacks run.

class ChargePayment
  include Easyop::Operation

  def call
    ctx.charge = Stripe::Charge.create(amount: ctx.total, source: ctx.token)
  end

  def rollback
    # Called automatically if a later step in the flow fails
    Stripe::Refund.create(charge: ctx.charge.id) if ctx.charge
  end
end

class CreateOrder
  include Easyop::Operation

  def call
    ctx.order = Order.create!(
      user:  ctx.user,
      total: ctx.total,
      charge_id: ctx.charge.id
    )
  end

  def rollback
    ctx.order.destroy! if ctx.order&.persisted?
  end
end
Rollback rules Only steps that completed successfully (i.e., their call returned without failing) are rolled back. The step that fails is NOT rolled back — only the ones that ran before it.

skip_if — Optional Steps

Declare on the operation class when it should be bypassed inside a flow:

class ApplyCoupon
  include Easyop::Operation

  skip_if { |ctx| !ctx.coupon_code? || ctx.coupon_code.to_s.empty? }

  def call
    coupon = Coupon.find_by(code: ctx.coupon_code)
    ctx.fail!(error: "Invalid coupon") unless coupon&.active?
    ctx.total    -= coupon.discount_amount
    ctx.discount  = coupon.discount_amount
  end
end

class ProcessCheckout
  include Easyop::Flow
  flow ValidateCart, ApplyCoupon, ChargePayment, CreateOrder
end

# ApplyCoupon is automatically skipped when coupon_code is absent or blank:
ProcessCheckout.call(user: user, cart: cart)
ProcessCheckout.call(user: user, cart: cart, coupon_code: "")
# Both skip ApplyCoupon entirely — it's never instantiated, never added to rollback list

# With a coupon:
ProcessCheckout.call(user: user, cart: cart, coupon_code: "SAVE10")
# ApplyCoupon runs normally
skip_if is a Flow concern skip_if is evaluated by the Flow runner before instantiating the step. Calling an operation directly — ApplyCoupon.call(...) — bypasses the skip check entirely. The step will always run when called directly.

Lambda Guards (Inline)

Place a lambda immediately before a step in the flow list to gate it. The lambda receives ctx and should return truthy to proceed, falsey to skip:

class ProcessCheckout
  include Easyop::Flow

  flow ValidateCart,
       ->(ctx) { ctx.coupon_code? && !ctx.coupon_code.to_s.empty? }, ApplyCoupon,
       ->(ctx) { ctx.gift_card? }, ApplyGiftCard,
       ChargePayment,
       CreateOrder
end

Choosing between skip_if and lambda guard:

  • Use skip_if on the operation class when the condition is intrinsic to that operation (e.g., "skip when no coupon")
  • Use a lambda guard in the flow when the condition depends on flow context or orchestration concerns

Nested Flows

A Flow can be a step inside another Flow. The inner flow shares the same ctx and participates in rollback normally.

class AuthAndValidate
  include Easyop::Flow
  flow AuthenticateUser, ValidatePermissions
end

class BillingFlow
  include Easyop::Flow
  flow ChargePayment, CreateInvoice
end

class FullCheckout
  include Easyop::Flow
  flow AuthAndValidate,   # nested flow — runs AuthenticateUser, ValidatePermissions
       ValidateCart,
       ApplyCoupon,
       BillingFlow,       # nested flow — runs ChargePayment, CreateInvoice
       SendConfirmation
end

prepare / FlowBuilder

FlowClass.prepare returns a FlowBuilder that lets you attach callbacks before executing the flow. This is the canonical entry point for controller integration.

Block callbacks

ProcessCheckout.prepare
  .on_success { |ctx| redirect_to order_path(ctx.order) }
  .on_failure { |ctx| flash[:error] = ctx.error; redirect_back }
  .call(user: current_user, cart: current_cart, coupon_code: params[:coupon])

Symbol callbacks with bind_with

# Bind a host object (e.g. a Rails controller) to dispatch to named methods:
def create
  ProcessCheckout.prepare
    .bind_with(self)
    .on(success: :order_created, fail: :checkout_failed)
    .call(user: current_user, cart: current_cart, coupon_code: params[:coupon])
end

private

def order_created(ctx)
  redirect_to order_path(ctx.order), notice: "Order placed!"
end

def checkout_failed(ctx)
  flash[:error] = ctx.error
  render :new
end

Zero-arity methods

If the bound method accepts no arguments, ctx is not passed:

def order_created    # no ctx argument
  redirect_to orders_path
end

Multiple callbacks

ProcessCheckout.prepare
  .on_success { |ctx| Analytics.track("checkout", order_id: ctx.order.id) }
  .on_success { |ctx| redirect_to order_path(ctx.order) }
  .on_failure { |ctx| Rails.logger.error "Checkout failed: #{ctx.error}" }
  .on_failure { |ctx| render json: { error: ctx.error }, status: 422 }
  .call(attrs)

.call vs .call! on Flows

Flows support the same two entry points as single operations:

# .call — returns ctx, never raises on ctx.fail!
result = ProcessCheckout.call(attrs)
result.success?  # check result

# .call! — raises Easyop::Ctx::Failure on failure
begin
  result = ProcessCheckout.call!(attrs)
rescue Easyop::Ctx::Failure => e
  e.ctx.error  # => the error message from whichever step failed
end
EasyOp v0.1.0 · MIT License · GitHub