EasyOp
📖 Documentation | GitHub | Changelog
A joyful, opinionated Ruby gem for wrapping business logic in composable operations.
class AuthenticateUser
include Easyop::Operation
def call
user = User.authenticate(ctx.email, ctx.password)
ctx.fail!(error: "Invalid credentials") unless user
ctx.user = user
end
end
result = AuthenticateUser.call(email: "alice@example.com", password: "hunter2")
result.success? # => true
result.user # => #<User ...>
Installation
# Gemfile
gem "easyop"
bundle install
Quick Start
Every operation:
- includes
Easyop::Operation - defines a
callmethod that reads/writesctx - returns
ctxfrom.call— the shared data bag that doubles as the result object
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
result = DoubleNumber.call(number: "oops")
result.failure? # => true
result.error # => "input must be a number"
The ctx Object
ctx is the shared data bag — a Hash-backed object with method-style attribute access. It is passed in from the caller and returned as the result.
# Reading
ctx.email # method access
ctx[:email] # hash-style access
ctx.admin? # predicate: !!ctx[:admin]
ctx.key?(:email) # explicit existence check (true/false)
# Writing
ctx.user = user
ctx[:user] = user
ctx.merge!(user: user, token: "abc")
# Extracting a subset
ctx.slice(:name, :email) # => { name: "Alice", email: "alice@example.com" }
ctx.to_h # => plain Hash copy of all attributes
# Status
ctx.success? # true unless fail! was called
ctx.ok? # alias for success?
ctx.failure? # true after fail!
ctx.failed? # alias for failure?
# Fail fast
ctx.fail! # mark failed
ctx.fail!(error: "Bad input") # merge attrs then fail
ctx.fail!(error: "Validation failed", errors: { email: "is blank" })
# Error helpers
ctx.error # => ctx[:error]
ctx.errors # => ctx[:errors] || {}
Ctx::Failure exception
ctx.fail! raises Easyop::Ctx::Failure, a StandardError subclass. The exception's .ctx attribute holds the failed context, and .message is formatted as:
"Operation failed" # when ctx.error is nil
"Operation failed: <ctx.error>" # when ctx.error is set
begin
AuthenticateUser.call!(email: email, password: password)
rescue Easyop::Ctx::Failure => e
e.ctx.error # => "Invalid credentials"
e. # => "Operation failed: Invalid credentials"
end
Rollback tracking (called! / rollback!)
These methods are used internally by Easyop::Flow to track which operations have run and to roll them back on failure. You generally do not call them directly, but they are part of the public Ctx API:
ctx.called!(operation_instance) # register an operation as having run
ctx.rollback! # roll back all registered operations in reverse order
rollback! is idempotent — calling it more than once has no additional effect.
Chainable callbacks (post-call)
AuthenticateUser.call(email: email, password: password)
.on_success { |ctx| sign_in(ctx.user) }
.on_failure { |ctx| flash[:alert] = ctx.error }
Pattern matching (Ruby 3+)
case AuthenticateUser.call(email: email, password: password)
in { success: true, user: }
sign_in(user)
in { success: false, error: }
flash[:alert] = error
end
Bang variant
# .call — returns ctx, swallows failures (check ctx.failure?)
# .call! — returns ctx on success, raises Easyop::Ctx::Failure on failure
begin
ctx = AuthenticateUser.call!(email: email, password: password)
rescue Easyop::Ctx::Failure => e
e.ctx.error # => "Invalid credentials"
end
Hooks
class NormalizeEmail
include Easyop::Operation
before :strip_whitespace
after :log_result
around :with_timing
def call
ctx.normalized = ctx.email.downcase
end
private
def strip_whitespace
ctx.email = ctx.email.to_s.strip
end
def log_result
Rails.logger.info "Normalized: #{ctx.normalized}" if ctx.success?
end
def with_timing
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
Rails.logger.info "Took #{((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)}ms"
end
end
Multiple hooks run in declaration order. after hooks always run (even on failure). Hooks can be method names (Symbol) or inline blocks:
before { ctx.email = ctx.email.to_s.strip.downcase }
after { Rails.logger.info ctx.inspect }
around { |inner| Sentry.with_scope { inner.call } }
rescue_from
Handle exceptions without polluting call with begin/rescue blocks:
class ParseJson
include Easyop::Operation
rescue_from JSON::ParserError do |e|
ctx.fail!(error: "Invalid JSON: #{e.}")
end
def call
ctx.parsed = JSON.parse(ctx.raw)
end
end
Multiple handlers and with: method reference syntax:
class ImportData
include Easyop::Operation
rescue_from CSV::MalformedCSVError, with: :handle_bad_csv
rescue_from ActiveRecord::RecordInvalid do |e|
ctx.fail!(error: e., errors: e.record.errors.to_h)
end
def call
# ...
end
private
def handle_bad_csv(e)
ctx.fail!(error: "CSV is malformed: #{e.}")
end
end
Handlers are checked in reverse inheritance order — child class handlers take priority over parent class handlers.
Typed Input/Output Schemas
Schemas are optional. Declare them to get early validation and inline documentation:
class RegisterUser
include Easyop::Operation
params do
required :email, String
required :age, Integer
optional :plan, String, default: "free"
optional :admin, :boolean, default: false
end
result do
required :user, User
end
def call
ctx.user = User.create!(ctx.slice(:email, :age, :plan))
end
end
result = RegisterUser.call(email: "alice@example.com", age: 30)
result.success? # => true
result.plan # => "free" (default applied)
result = RegisterUser.call(email: "bob@example.com")
result.failure? # => true
result.error # => "Missing required params field: age"
Type shorthands
| Symbol | Resolves to |
|---|---|
:boolean |
`TrueClass \ |
:string |
String |
:integer |
Integer |
:float |
Float |
:symbol |
Symbol |
:any |
any value |
Pass any Ruby class directly: required :user, User.
inputs / outputs aliases
inputs is an alias for params, and outputs is an alias for result. They are interchangeable:
class NormalizeAddress
include Easyop::Operation
inputs do
required :street, String
required :city, String
end
outputs do
required :formatted, String
end
def call
ctx.formatted = "#{ctx.street}, #{ctx.city}"
end
end
Configuration
Easyop.configure do |c|
c.strict_types = false # true = ctx.fail! on type mismatch; false = warn (default)
c.type_adapter = :native # :none, :native (default), :literal, :dry, :active_model
end
Reset to defaults (useful in tests):
Easyop.reset_config!
Flow — Composing Operations
Easyop::Flow runs operations in sequence, sharing one ctx. Any failure halts the chain.
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 ...>
Rollback
Each step can define rollback. On failure, rollback runs on all completed steps in reverse:
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
skip_if — Optional steps
Declare when a step should be bypassed:
class ApplyCoupon
include Easyop::Operation
skip_if { |ctx| !ctx.coupon_code? || ctx.coupon_code.to_s.empty? }
def call
ctx.discount = CouponService.apply(ctx.coupon_code)
end
end
class ProcessCheckout
include Easyop::Flow
flow ValidateCart,
ApplyCoupon, # automatically skipped when no coupon_code
ChargePayment,
CreateOrder
end
Skipped steps are never added to the rollback list.
Note:
skip_ifis evaluated by the Flow runner. Calling an operation directly (e.g.MyOp.call(...)) bypasses the skip check entirely —skip_ifis a Flow concept, not an operation-level guard.
Lambda guards (inline)
Place a lambda immediately before a step to gate it:
flow ValidateCart,
->(ctx) { ctx.coupon_code? }, ApplyCoupon,
ChargePayment
Nested flows
A Flow can be a step inside another Flow:
class ProcessOrder
include Easyop::Flow
flow ValidateCart, ChargePayment
end
class FullCheckout
include Easyop::Flow
flow ProcessOrder, SendConfirmation, NotifyAdmin
end
prepare — Pre-registered Callbacks
FlowClass.prepare returns a FlowBuilder that accumulates callbacks before executing the flow. The flow class method is reserved for declaring steps — prepare is the clear, unambiguous entry point for callback registration.
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:
# In a Rails controller:
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)
end
def checkout_failed(ctx)
flash[:error] = ctx.error
render :new
end
Zero-arity methods are supported (ctx is not passed):
def order_created
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)
Inheritance — Shared Base Class
class ApplicationOperation
include Easyop::Operation
rescue_from StandardError do |e|
Sentry.capture_exception(e)
ctx.fail!(error: "An unexpected error occurred")
end
end
class MyOp < ApplicationOperation
def call
# StandardError is caught and handled by ApplicationOperation
end
end
Plugins
EasyOp has an opt-in plugin system. Plugins are installed on an operation class (or a shared base class) with the plugin DSL. Every subclass inherits the plugins of its parent.
class ApplicationOperation
include Easyop::Operation
plugin Easyop::Plugins::Instrumentation
plugin Easyop::Plugins::Recording, model: OperationLog
plugin Easyop::Plugins::Async, queue: "operations"
end
You can inspect which plugins have been installed on an operation class:
ApplicationOperation._registered_plugins
# => [
# { plugin: Easyop::Plugins::Instrumentation, options: {} },
# { plugin: Easyop::Plugins::Recording, options: { model: OperationLog } },
# { plugin: Easyop::Plugins::Async, options: { queue: "operations" } }
# ]
Plugins are not required automatically — require the ones you use:
require "easyop/plugins/instrumentation"
require "easyop/plugins/recording"
require "easyop/plugins/async"
Plugin: Instrumentation
Emits an ActiveSupport::Notifications event after every operation call. Requires ActiveSupport (included with Rails).
require "easyop/plugins/instrumentation"
class ApplicationOperation
include Easyop::Operation
plugin Easyop::Plugins::Instrumentation
end
Event: "easyop.operation.call"
Payload:
| Key | Type | Description |
|---|---|---|
:operation |
String | Class name, e.g. "Users::Register" |
:success |
Boolean | true unless ctx.fail! was called |
:error |
String \ | nil |
:duration |
Float | Elapsed milliseconds |
:ctx |
Easyop::Ctx |
The result object |
Subscribe manually:
ActiveSupport::Notifications.subscribe("easyop.operation.call") do |event|
p = event.payload
Rails.logger.info "[#{p[:operation]}] #{p[:success] ? 'ok' : 'FAILED'} (#{event.duration.round(1)}ms)"
end
Built-in log subscriber — add this to an initializer for zero-config logging:
# config/initializers/easyop.rb
Easyop::Plugins::Instrumentation.attach_log_subscriber
Output format:
[EasyOp] Users::Register ok (4.2ms)
[EasyOp] Users::Authenticate FAILED (1.1ms) — Invalid email or password
Plugin: Recording
Persists every operation execution to an ActiveRecord model. Useful for audit trails, debugging, and performance monitoring.
require "easyop/plugins/recording"
class ApplicationOperation
include Easyop::Operation
plugin Easyop::Plugins::Recording, model: OperationLog
end
Options:
| Option | Default | Description |
|---|---|---|
model: |
required | ActiveRecord class to write logs into |
record_params: |
true |
Set false to skip serializing ctx params |
Required model columns:
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 — ctx attrs (sensitive keys scrubbed)
t.float :duration_ms
t.datetime :performed_at, null: false
end
The plugin automatically scrubs these keys from params_data before persisting: :password, :password_confirmation, :token, :secret, :api_key. ActiveRecord objects are serialized as { id:, class: } rather than their full representation.
Opt out per class:
class Newsletter::SendBroadcast < ApplicationOperation
recording false # skip logging for this operation
end
Recording failures are swallowed and logged as warnings — a failed log write never breaks the operation.
Plugin: Async
Adds .call_async to any operation class, enqueuing execution as an ActiveJob. Requires ActiveJob (included with Rails).
require "easyop/plugins/async"
class Newsletter::SendBroadcast < ApplicationOperation
plugin Easyop::Plugins::Async, queue: "broadcasts"
end
Enqueue immediately:
Newsletter::SendBroadcast.call_async(subject: "Hello", body: "World")
With scheduling:
# Run after a delay
Newsletter::SendBroadcast.call_async(attrs, wait: 10.minutes)
# Run at a specific time
Newsletter::SendBroadcast.call_async(attrs, wait_until: Date.tomorrow.noon)
# Override the queue at call time
Newsletter::SendBroadcast.call_async(attrs, queue: "low_priority")
queue DSL — declare the default queue directly on a class (or a shared base class) instead of at plugin install time:
class Weather::BaseOperation < ApplicationOperation
queue :weather # all Weather ops use the "weather" queue by default
end
class Weather::CleanupExpiredDays < Weather::BaseOperation
queue :low_priority # override just for this class
end
The queue setting is inherited by subclasses and can be overridden at any level. Accepts Symbol or String. A per-call queue: argument to .call_async always takes precedence.
ActiveRecord objects are serialized by (class, id) and re-fetched in the job:
# This works — Article is serialized as { "__ar_class" => "Article", "__ar_id" => 42 }
Newsletter::SendBroadcast.call_async(article: @article, subject: "Hello")
Only pass serializable values: String, Integer, Float, Boolean, nil, Hash, Array, or ActiveRecord::Base.
The plugin defines Easyop::Plugins::Async::Job lazily (on first call to .call_async) so you can require the plugin before ActiveJob loads.
Plugin: Events
Emits domain events after an operation completes. Unlike the Instrumentation plugin (which is for operation-level tracing), Events carries business domain events through a configurable bus.
require "easyop/events/event"
require "easyop/events/bus"
require "easyop/events/bus/memory"
require "easyop/events/registry"
require "easyop/plugins/events"
require "easyop/plugins/event_handlers"
class PlaceOrder < ApplicationOperation
plugin Easyop::Plugins::Events
emits "order.placed", on: :success, payload: [:order_id, :total]
emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
emits "order.attempted", on: :always
def call
ctx.order_id = Order.create!(ctx.to_h).id
end
end
emits DSL options:
| Option | Values | Default | Description |
|---|---|---|---|
on: |
:success, :failure, :always |
:success |
When to fire |
payload: |
Proc, Array, nil |
nil (full ctx) |
Proc receives ctx; Array slices ctx keys |
guard: |
Proc, nil |
nil |
Extra condition — fires only when truthy |
Events fire in an ensure block and are emitted even when call! raises. Individual publish failures are swallowed and never crash the operation. emits declarations are inherited by subclasses.
Plugin: EventHandlers
Wires an operation class as a domain event handler. Handler operations receive ctx.event (the Easyop::Events::Event object) and all payload keys merged into ctx.
class SendConfirmation < ApplicationOperation
plugin Easyop::Plugins::EventHandlers
on "order.placed"
def call
event = ctx.event # Easyop::Events::Event
order_id = ctx.order_id # payload keys merged into ctx
OrderMailer.confirm(order_id).deliver_later
end
end
Async dispatch (requires Plugins::Async also installed):
class IndexOrder < ApplicationOperation
plugin Easyop::Plugins::Async, queue: "indexing"
plugin Easyop::Plugins::EventHandlers
on "order.*", async: true
on "inventory.**", async: true, queue: "low"
def call
# ctx.event_data is a Hash when dispatched async (serializable for ActiveJob)
SearchIndex.reindex(ctx.order_id)
end
end
Glob patterns:
"order.*"— matches within one dot-segment (order.placed,order.shipped)"warehouse.**"— matches across segments (warehouse.stock.updated,warehouse.zone.moved)- Plain strings match exactly;
Regexpis also accepted
Registration happens at class-load time. Configure the bus before loading handler classes.
Events Bus — Configurable Transport
The bus adapter controls how events are delivered. Configure it once at boot:
# Default — in-process synchronous (great for tests and simple setups)
Easyop::Events::Registry.bus = :memory
# ActiveSupport::Notifications (integrates with Rails instrumentation)
Easyop::Events::Registry.bus = :active_support
# Any custom adapter (RabbitMQ, Kafka, Redis Pub/Sub…)
Easyop::Events::Registry.bus = MyRabbitBus.new
# Or via config block:
Easyop.configure { |c| c.event_bus = :active_support }
Built-in adapters:
| Adapter | Class | Notes |
|---|---|---|
| Memory | Easyop::Events::Bus::Memory |
Default. Thread-safe, in-process, synchronous. |
| ActiveSupport | Easyop::Events::Bus::ActiveSupportNotifications |
Wraps AS::Notifications. Requires activesupport. |
| Custom | Easyop::Events::Bus::Custom |
Wraps any object with #publish and #subscribe. |
Building a custom bus — two approaches:
Option A — subclass Bus::Adapter (recommended for real transports). Inherits glob helpers, _safe_invoke, and _compile_pattern:
require "easyop/events/bus/adapter"
# Decorator: wraps any inner bus and adds structured logging
class LoggingBus < Easyop::Events::Bus::Adapter
def initialize(inner = Easyop::Events::Bus::Memory.new)
super()
@inner = inner
end
def publish(event)
Rails.logger.info "[bus:publish] #{event.name} payload=#{event.payload}"
@inner.publish(event)
end
def subscribe(pattern, &block) = @inner.subscribe(pattern, &block)
def unsubscribe(handle) = @inner.unsubscribe(handle)
end
Easyop::Events::Registry.bus = LoggingBus.new
# Full RabbitMQ example (Bunny gem) — uses _safe_invoke for handler safety:
class RabbitBus < Easyop::Events::Bus::Adapter
EXCHANGE_NAME = "easyop.events"
def initialize(url = ENV.fetch("AMQP_URL", "amqp://localhost"))
super()
@url = url; @mutex = Mutex.new; @handles = {}
end
def publish(event)
exchange.publish(event.to_h.to_json,
routing_key: event.name, content_type: "application/json")
end
def subscribe(pattern, &block)
q = channel.queue("", exclusive: true, auto_delete: true)
q.bind(exchange, routing_key: _to_amqp(pattern))
consumer = q.subscribe { |_, _, body| _safe_invoke(block, decode(body)) }
handle = Object.new
@mutex.synchronize { @handles[handle.object_id] = { queue: q, consumer: consumer } }
handle
end
def unsubscribe(handle)
@mutex.synchronize do
e = @handles.delete(handle.object_id); return unless e
e[:consumer].cancel; e[:queue].delete
end
end
def disconnect
@mutex.synchronize { @connection&.close; @connection = @channel = @exchange = nil }
end
private
def _to_amqp(p) = p.is_a?(Regexp) ? p.source : p.gsub("**", "#")
def decode(body) = Easyop::Events::Event.new(**JSON.parse(body, symbolize_names: true))
def connection = @connection ||= Bunny.new(@url, recover_from_connection_close: true).tap(&:start)
def channel = @channel ||= connection.create_channel
def exchange = @exchange ||= channel.topic(EXCHANGE_NAME, durable: true)
end
Easyop::Events::Registry.bus = RabbitBus.new
at_exit { Easyop::Events::Registry.bus.disconnect }
Option B — duck-typed object (no subclassing). Pass any object with #publish and #subscribe; Registry auto-wraps it in Bus::Custom:
class MyKafkaBus
def publish(event) = Kafka.produce(event.name, event.to_h.to_json)
def subscribe(pattern, &block) = Kafka.subscribe(pattern) { |msg| block.call(decode(msg)) }
end
Easyop::Events::Registry.bus = MyKafkaBus.new
Plugin: Transactional
Wraps every operation call in a database transaction. On ctx.fail! or any unhandled exception the transaction is rolled back. Supports ActiveRecord and Sequel.
require "easyop/plugins/transactional"
# Per operation:
class TransferFunds < ApplicationOperation
plugin Easyop::Plugins::Transactional
def call
ctx.from_account.debit!(ctx.amount)
ctx.to_account.credit!(ctx.amount)
ctx.transaction_id = SecureRandom.uuid
end
end
# Or globally on ApplicationOperation — all subclasses get transactions:
class ApplicationOperation
include Easyop::Operation
plugin Easyop::Plugins::Transactional
end
Also works with the classic include style:
class TransferFunds
include Easyop::Operation
include Easyop::Plugins::Transactional
end
Opt out per class when the parent has transactions enabled:
class ReadOnlyReport < ApplicationOperation
transactional false # no transaction overhead for read-only ops
end
Options: none — the adapter is detected automatically (ActiveRecord first, then Sequel).
Placement in the lifecycle: The transaction wraps the entire prepare chain — before hooks, call, and after hooks all run inside the same transaction. If ctx.fail! is called (raising Ctx::Failure), the transaction rolls back.
With Flow: When using Easyop::Plugins::Transactional inside a Flow step, the transaction is scoped to that one step, not the whole flow. For a flow-wide transaction, include it on the flow class itself.
Building Your Own Plugin
A plugin is any object responding to .install(base_class, **options). Inherit from Easyop::Plugins::Base for a clear interface:
require "easyop/plugins/base"
module MyPlugin < Easyop::Plugins::Base
def self.install(base, **options)
# 1. Prepend a module to wrap _easyop_run (wraps the entire lifecycle)
base.prepend(RunWrapper)
# 2. Extend to add class-level DSL
base.extend(ClassMethods)
# 3. Store configuration on the class
base.instance_variable_set(:@_my_plugin_option, options[:my_option])
end
module ClassMethods
# DSL method for subclasses to configure the plugin
def my_plugin_option(value)
@_my_plugin_option = value
end
def _my_plugin_option
@_my_plugin_option ||
(superclass.respond_to?(:_my_plugin_option) ? superclass._my_plugin_option : nil)
end
end
module RunWrapper
# Override _easyop_run to wrap the full operation lifecycle.
# Always call super and return ctx.
def _easyop_run(ctx, raise_on_failure:)
# before
puts "Starting #{self.class.name}"
super.tap do
# after (ctx is fully settled — success? / failure? are final here)
puts "Finished #{self.class.name}: #{ctx.success? ? 'ok' : 'FAILED'}"
end
end
end
end
Activate it:
class ApplicationOperation
include Easyop::Operation
plugin MyPlugin, my_option: "value"
end
Per-class opt-out pattern (same pattern used by the Recording plugin):
module MyPlugin < Easyop::Plugins::Base
module ClassMethods
def my_plugin(enabled)
@_my_plugin_enabled = enabled
end
def _my_plugin_enabled?
return @_my_plugin_enabled if instance_variable_defined?(:@_my_plugin_enabled)
superclass.respond_to?(:_my_plugin_enabled?) ? superclass._my_plugin_enabled? : true
end
end
module RunWrapper
def _easyop_run(ctx, raise_on_failure:)
return super unless self.class._my_plugin_enabled?
# ... plugin logic
super
end
end
end
# Then in an operation:
class InternalOp < ApplicationOperation
my_plugin false
end
Plugin execution order is determined by the order plugin calls appear. Each plugin prepends its RunWrapper, so the last plugin installed is the outermost wrapper:
Plugin3::RunWrapper (outermost)
Plugin2::RunWrapper
Plugin1::RunWrapper
prepare { before → call → after } (innermost)
Naming convention: prefix all internal instance methods with _pluginname_ (e.g. _recording_persist!, _async_serialize) to avoid collisions with application code.
Rails Controller Integration
Pattern 1 — Inline callbacks
class UsersController < ApplicationController
def create
CreateUser.call(user_params)
.on_success { |ctx| redirect_to profile_path(ctx.user) }
.on_failure { |ctx| render :new, locals: { errors: ctx.errors } }
end
end
Pattern 2 — prepare and bind_with
class CheckoutsController < ApplicationController
def create
ProcessCheckout.prepare
.bind_with(self)
.on(success: :checkout_complete, fail: :checkout_failed)
.call(user: current_user, cart: current_cart, coupon_code: params[:coupon])
end
private
def checkout_complete(ctx)
redirect_to order_path(ctx.order), notice: "Order placed!"
end
def checkout_failed(ctx)
flash[:error] = ctx.error
render :new
end
end
Pattern 3 — Pattern matching
def create
case CreateUser.call(user_params)
in { success: true, user: }
redirect_to profile_path(user)
in { success: false, errors: Hash => errs }
render :new, locals: { errors: errs }
in { success: false, error: String => msg }
flash[:alert] = msg
render :new
end
end
Full Checkout Example
class ValidateCart
include Easyop::Operation
def call
ctx.fail!(error: "Cart is empty") if ctx.cart.items.empty?
ctx.total = ctx.cart.items.sum(&:price)
end
end
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 ChargePayment
include Easyop::Operation
def call
charge = Stripe::Charge.create(amount: ctx.total, source: ctx.payment_token)
ctx.charge = charge
end
def rollback
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: ctx.charge.id,
discount: ctx.discount
)
end
def rollback
ctx.order.destroy! if ctx.order
end
end
class SendConfirmation
include Easyop::Operation
def call
OrderMailer.confirmation(ctx.order).deliver_later
end
end
class ProcessCheckout
include Easyop::Flow
flow ValidateCart,
ApplyCoupon,
ChargePayment,
CreateOrder,
SendConfirmation
end
# Controller:
ProcessCheckout.prepare
.bind_with(self)
.on(success: :order_created, fail: :checkout_failed)
.call(
user: current_user,
cart: current_cart,
payment_token: params[:stripe_token],
coupon_code: params[:coupon_code]
)
Running Examples
ruby examples/usage.rb
Example Rails Apps
Two full Rails 8 applications live in /examples/. Neither is included in the gem — repository only.
Blog App — easyop_test_app
A blog + newsletter app demonstrating the full EasyOp feature set.
| Feature | Where to look |
|---|---|
| Basic operations | app/operations/users/, app/operations/articles/ |
Typed params schema |
app/operations/users/register.rb |
rescue_from |
app/operations/application_operation.rb |
| Flow with rollback | app/operations/flows/transfer_credits.rb |
skip_if / lambda guards |
Flows::TransferCredits::ApplyFee |
| Instrumentation plugin | ApplicationOperation → plugin Easyop::Plugins::Instrumentation |
| Recording plugin | ApplicationOperation → persists to operation_logs table |
| Async plugin | app/operations/newsletter/subscribe.rb |
| Transactional plugin | ApplicationOperation → all DB ops wrapped in transactions |
| Rails controller integration | app/controllers/articles_controller.rb, transfers_controller.rb |
cd examples/easyop_test_app
bundle install
bin/rails db:create db:migrate db:seed
bin/rails server -p 3002
Seed accounts: alice@example.com / password123 (500 credits), bob, carol, dave (0 credits — tests insufficient-funds error).
TicketFlow — ticketflow
A full event ticket-selling platform with modern Tailwind UI and admin panel. Every operation is powered by EasyOp.
| Feature | Where to look |
|---|---|
| Multi-step checkout Flow | app/operations/flows/checkout.rb — 6 chained operations |
skip_if (optional discount step) |
app/operations/orders/apply_discount.rb |
| Rollback on payment failure | app/operations/orders/process_payment.rb#rollback |
prepare + callbacks in controller |
app/controllers/checkouts_controller.rb |
| Recording plugin → operation logs | app/operations/application_operation.rb |
| Admin metrics dashboard | app/controllers/admin/dashboard_controller.rb |
| Admin order refund operation | app/operations/admin/refund_order.rb |
| Virtual ticket generation | app/operations/tickets/generate_tickets.rb |
cd examples/ticketflow
bundle install
bin/rails db:create db:migrate db:seed
bin/rails server -p 3001
Seed accounts: admin@ticketflow.com / password123 (admin), user@ticketflow.com / password123 (customer).
Discount codes: SAVE10 (10% off), FLAT20 ($20 off), VIP50 (50% off).
Running Specs
bundle exec rspec
AI Tools — Claude Skill & LLM Context
EasyOp ships with two sets of AI helpers so any LLM can write idiomatic operations without you re-explaining the API.
Claude Code Skill
Copy the plugin into your project and Claude will auto-activate whenever you mention operations, flows, or easyop:
# From your project root
cp -r path/to/easyop/claude-plugin/.claude-plugin .
cp -r path/to/easyop/claude-plugin/skills .
Or reference it from your existing CLAUDE.md:
## EasyOp
@path/to/easyop/claude-plugin/skills/easyop/SKILL.md
Once installed, Claude generates correct boilerplate for operations, flows, rollback, plugins, and RSpec tests — no copy-pasting the README.
LLM Context Files (llms/)
| File | When to use |
|---|---|
llms/overview.md |
Before asking an AI to modify or extend the gem — covers the full file map and plugin architecture |
llms/usage.md |
Before asking an AI to write application code — covers all patterns: basic ops, flows, plugins, Rails integration, testing |
Paste either file as a system message in Claude.ai / ChatGPT / Gemini, or use Cursor's "Add to context" feature before asking your question.
See the AI Tools docs page for full details including programmatic usage with the Anthropic API.
Module Reference
Core
| Class/Module | Description |
|---|---|
Easyop::Operation |
Core mixin — include in any class to make it an operation |
Easyop::Flow |
Includes Operation; adds flow DSL and sequential execution |
Easyop::FlowBuilder |
Builder returned by FlowClass.prepare |
Easyop::Ctx |
The shared context/result object |
Easyop::Ctx::Failure |
Raised by ctx.fail!; rescued by .call, propagated by .call! |
Easyop::Hooks |
before/after/around hook system (no ActiveSupport) |
Easyop::Rescuable |
rescue_from DSL |
Easyop::Skip |
skip_if DSL for conditional step execution in flows |
Easyop::Schema |
params/result typed schema DSL |
Plugins (opt-in)
| Class/Module | Require | Description |
|---|---|---|
Easyop::Plugins::Base |
easyop/plugins/base |
Abstract base — inherit to build custom plugins |
Easyop::Plugins::Instrumentation |
easyop/plugins/instrumentation |
Emits "easyop.operation.call" via ActiveSupport::Notifications |
Easyop::Plugins::Recording |
easyop/plugins/recording |
Persists every execution to an ActiveRecord model |
Easyop::Plugins::Async |
easyop/plugins/async |
Adds .call_async via ActiveJob with AR object serialization |
Easyop::Plugins::Async::Job |
(created lazily) | The ActiveJob class that deserializes and runs the operation |
Easyop::Plugins::Transactional |
easyop/plugins/transactional |
Wraps operation in an AR/Sequel transaction; transactional false to opt out |
Easyop::Plugins::Events |
easyop/plugins/events |
Emits domain events after execution; emits DSL with on:, payload:, guard: |
Easyop::Plugins::EventHandlers |
easyop/plugins/event_handlers |
Subscribes an operation to handle domain events; on DSL with glob patterns |
Domain Events Infrastructure
| Class/Module | Require | Description |
|---|---|---|
Easyop::Events::Event |
easyop/events/event |
Immutable frozen value object: name, payload, metadata, timestamp, source |
Easyop::Events::Bus::Base |
easyop/events/bus |
Abstract adapter interface (publish, subscribe, unsubscribe) and glob helpers |
Easyop::Events::Bus::Adapter |
easyop/events/bus/adapter |
Inheritable base for custom buses. Adds _safe_invoke + _compile_pattern (memoized). Subclass this. |
Easyop::Events::Bus::Memory |
easyop/events/bus/memory |
In-process synchronous bus (default). Thread-safe. |
Easyop::Events::Bus::ActiveSupportNotifications |
easyop/events/bus/active_support_notifications |
ActiveSupport::Notifications adapter |
Easyop::Events::Bus::Custom |
easyop/events/bus/custom |
Wraps any user-provided bus object (duck-typed, no subclassing needed) |
Easyop::Events::Registry |
easyop/events/registry |
Global bus holder + handler subscription registry |
Releasing a New Version
Follow these steps to bump the version, update the changelog, and publish a tagged release.
1. Bump the version number
Edit lib/easyop/version.rb and increment the version string following Semantic Versioning:
# lib/easyop/version.rb
module Easyop
VERSION = "0.1.4" # was 0.1.3
end
2. Update the changelog
In CHANGELOG.md, move everything under [Unreleased] into a new versioned section:
## [Unreleased]
## [0.1.4] — YYYY-MM-DD # ← new section
### Added
- …
## [0.1.3] — 2026-04-14
Add a comparison link at the bottom of the file:
[Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.4...HEAD
[0.1.4]: https://github.com/pniemczyk/easyop/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
3. Commit the release changes
git add lib/easyop/version.rb CHANGELOG.md
git commit -m "Release v0.1.4"
4. Tag the commit
git tag -a v0.1.4 -m "Release v0.1.4"
5. Push the commit and tag
git push origin master
git push origin v0.1.4
6. Build and push the gem (optional)
gem build easyop.gemspec
gem push easyop-0.1.4.gem