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:
- Checks
skip?— ifskip_ifis declared and truthy, the step is skipped entirely (not added to the rollback list) - Checks inline lambda guards (if a
->(ctx){}precedes the step) - Creates an instance of the step class
- Calls
instance._easyop_run(ctx, raise_on_failure: true)— the step runs with full hook and rescue support - On success: calls
ctx.called!(instance)to register for rollback - On failure (
Ctx::Failureraised): stops the chain, callsctx.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
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 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_ifon 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