EasyOp โ Joyful, composable business logic for Ruby
Wrap every piece of business logic in a small, testable operation object. Compose operations into flows. Never entangle controllers or models with complex logic again.
Quick Example
An operation is any Ruby class that includes Easyop::Operation and defines a call method. The shared context ctx is the input carrier and result object.
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 id=1>
result = AuthenticateUser.call(email: "bob@example.com", password: "wrong")
result.failure? # => true
result.error # => "Invalid credentials"
Features
Operation
Pure Ruby objects with a call method, typed schemas, hooks, and error handling. No framework required.
Flow
Compose operations in sequence sharing one context. Automatic rollback on failure. Conditional skip_if steps.
Hooks
before, after, and around hooks with inheritance. Works without ActiveSupport.
Schema
Optional typed params / result declarations. Catches missing or wrong-type inputs before call runs.
Plugins
Instrumentation, Recording, Async, Transactional โ opt-in, inheritable, composable.
Testing
Operations are plain Ruby objects. Call them directly in specs โ no stubs, no HTTP, no framework needed.
How It Works
Every operation receives a ctx โ a Hash-backed object with method-style attribute access. Your code reads inputs from ctx and writes outputs back to it. When something goes wrong, call ctx.fail! to halt and mark the operation as failed.
The caller receives ctx as the result and inspects ctx.success? or ctx.failure?. No exceptions are raised unless you use the bang variant .call!.
# .call โ returns ctx, never raises on ctx.fail!
result = MyOp.call(input: "data")
result.success? # => true or false
result.some_attr # => whatever the op set
# .call! โ returns ctx on success, raises Easyop::Ctx::Failure on fail!
begin
result = MyOp.call!(input: "data")
rescue Easyop::Ctx::Failure => e
e.ctx.error # => the error message
e.message # => "Operation failed: the error message"
end
Flow: Composing Operations
A Flow runs operations in sequence, sharing one ctx. Any failure halts the chain and triggers rollback of completed steps.
class ProcessCheckout
include Easyop::Flow
flow ValidateCart,
ApplyCoupon, # declared skip_if โ skipped when no coupon_code
ChargePayment, # defines rollback โ refunds on failure
CreateOrder,
SendConfirmation
end
result = ProcessCheckout.call(user: current_user, cart: current_cart)
result.success? # => true
result.order # => #<Order id=42>
Module Map
| Module | Description |
|---|---|
Easyop::Operation | Core mixin โ include in any class |
Easyop::Flow | Sequential operation chain with rollback |
Easyop::FlowBuilder | Builder returned by FlowClass.prepare |
Easyop::Ctx | Shared context + result object |
Easyop::Ctx::Failure | Exception raised by ctx.fail! |
Easyop::Hooks | before/after/around hook system |
Easyop::Rescuable | rescue_from DSL |
Easyop::Skip | skip_if for conditional flow steps |
Easyop::Schema | Typed params/result DSL |
Easyop::Plugins::Instrumentation | ActiveSupport::Notifications events |
Easyop::Plugins::Recording | Persists executions to ActiveRecord |
Easyop::Plugins::Async | Background jobs via ActiveJob |
Easyop::Plugins::Transactional | DB transaction wrapper |