EasyOp Docs Rails Integration

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
Best for Simple actions where the success/failure logic fits naturally in a one-liner.

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
Best for Controllers where success and failure require multiple lines, redirects, or render calls. Keeps the action method clean and delegates rendering to named private methods.

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
Best for Complex response branches where different failure modes require different responses (e.g., validation errors vs. authorization failures vs. unexpected errors).

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
Use with care .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
EasyOp v0.1.0 · MIT License · GitHub