Rails Integration
EasyOp integrates naturally with Rails. No generators required — just add a base class, configure plugins in an initializer, and choose a controller pattern that fits your team.
ApplicationOperation Base Class
Create a base class in app/operations/application_operation.rb. All your operations inherit from it, sharing plugins and rescue handlers.
# app/operations/application_operation.rb
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
Initializer — Plugin Setup
# config/initializers/easyop.rb
require "easyop/plugins/instrumentation"
require "easyop/plugins/recording"
require "easyop/plugins/async"
require "easyop/plugins/transactional"
Easyop.configure do |c|
c.strict_types = Rails.env.development? || Rails.env.test?
end
class ApplicationOperation
plugin Easyop::Plugins::Instrumentation
plugin Easyop::Plugins::Recording, model: OperationLog
plugin Easyop::Plugins::Async, queue: "operations"
plugin Easyop::Plugins::Transactional
end
# Attach the built-in log subscriber:
Easyop::Plugins::Instrumentation.attach_log_subscriber
OperationLog Migration
# db/migrate/20240101000000_create_operation_logs.rb
class CreateOperationLogs < ActiveRecord::Migration[7.1]
def change
create_table :operation_logs do |t|
t.string :operation_name, null: false
t.boolean :success, null: false
t.string :error_message
t.text :params_data # JSON — sensitive keys scrubbed automatically
t.float :duration_ms
t.datetime :performed_at, null: false
end
add_index :operation_logs, :operation_name
add_index :operation_logs, :performed_at
add_index :operation_logs, :success
end
end
Pattern 1 — Inline Callbacks
The simplest pattern. Chain .on_success and .on_failure directly on the result.
class UsersController < ApplicationController
def create
Users::Register.call(user_params)
.on_success { |ctx| redirect_to profile_path(ctx.user), notice: "Welcome!" }
.on_failure { |ctx| render :new, locals: { errors: ctx.errors }, status: :unprocessable_entity }
end
private
def user_params
params.require(:user).permit(:email, :name, :password)
end
end
Pattern 2 — prepare and bind_with
Pre-register named methods as callbacks. The cleanest pattern for controllers with non-trivial response logic.
class CheckoutsController < ApplicationController
def create
ProcessCheckout.prepare
.bind_with(self)
.on(success: :checkout_complete, fail: :checkout_failed)
.call(
user: current_user,
cart: current_cart,
payment_token: params[:stripe_token],
coupon_code: params[:coupon_code]
)
end
private
def checkout_complete(ctx)
redirect_to order_path(ctx.order), notice: "Order placed! Confirmation sent to #{current_user.email}."
end
def checkout_failed(ctx)
flash.now[:error] = ctx.error
render :new, status: :unprocessable_entity
end
end
Zero-arity methods
If your callback methods don't need ctx, omit the argument:
def checkout_complete
redirect_to orders_path, notice: "Order placed!"
end
Pattern 3 — Pattern Matching
Ruby 3+ pattern matching reads naturally for multi-branch responses.
class UsersController < ApplicationController
def create
case Users::Register.call(user_params)
in { success: true, user: User => user }
sign_in(user)
redirect_to dashboard_path, notice: "Welcome to the app!"
in { success: false, errors: Hash => errs } if errs.any?
@errors = errs
render :new, status: :unprocessable_entity
in { success: false, error: String => msg }
flash.now[:alert] = msg
render :new, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:email, :name, :password, :password_confirmation)
end
end
Pattern 4 — .call! with rescue
Use when an outer mechanism handles all errors — e.g., a rescue_from in ApplicationController.
class ApplicationController < ActionController::Base
rescue_from Easyop::Ctx::Failure do |e|
respond_to do |format|
format.html { redirect_back fallback_location: root_path, alert: e.ctx.error }
format.json { render json: { error: e.ctx.error, errors: e.ctx.errors }, status: :unprocessable_entity }
end
end
end
class OrdersController < ApplicationController
def create
ctx = ProcessOrder.call!(order_params)
redirect_to order_path(ctx.order), notice: "Order created!"
end
end
.call! is best for simple actions where you are OK with a generic error response for all failures. For fine-grained failure handling, prefer patterns 1, 2, or 3.
Full Controller Example
A real-world controller for a checkout flow using Pattern 2:
# app/controllers/checkouts_controller.rb
class CheckoutsController < ApplicationController
before_action :authenticate_user!
def new
@cart = current_user.cart
end
def create
ProcessCheckout.prepare
.bind_with(self)
.on(success: :order_created, fail: :order_failed)
.call(
user: current_user,
cart: current_cart,
payment_token: params[:stripe_token],
coupon_code: params[:coupon_code].presence
)
end
private
def order_created(ctx)
session.delete(:cart_id)
redirect_to order_path(ctx.order), notice: "Order ##{ctx.order.id} placed successfully!"
end
def order_failed(ctx)
flash.now[:error] = ctx.error
@cart = current_cart
@errors = ctx.errors
render :new, status: :unprocessable_entity
end
end
Operation Organization
A suggested directory structure for a mid-size Rails app:
app/
operations/
application_operation.rb # base class
users/
register.rb # Users::Register
authenticate.rb # Users::Authenticate
update_profile.rb # Users::UpdateProfile
orders/
process_checkout.rb # Orders::ProcessCheckout (flow)
validate_cart.rb
charge_payment.rb
create_order.rb
send_confirmation.rb
reports/
generate_pdf.rb
API Controllers
For JSON APIs, pattern matching on the result works well:
class Api::V1::UsersController < Api::BaseController
def create
case Users::Register.call(user_params)
in { success: true, user: }
render json: UserSerializer.new(user), status: :created
in { success: false, errors: Hash => errs } if errs.any?
render json: { errors: errs }, status: :unprocessable_entity
in { success: false, error: String => msg }
render json: { error: msg }, status: :unprocessable_entity
end
end
end