Wild logic,
tamed objects.
A framework for making service-style Ruby feel predictable:
define the work, execute the task, handle the result
Controllers love to do too much.
Sound familiar? One action finds records, calls Stripe, rescues errors, fires mailers, and hopes for the best. CMDx moves that story into a plain Ruby class you can test and reuse.
class PaymentsController < ApplicationController
before_action :authenticate_user!
def create
order = current_user.orders.find(params[:order_id])
return redirect_to(order) if order.paid?
if params[:payment_method_id].blank?
flash.now[:alert] = "Choose a payment method"
return render :new, status: :unprocessable_entity
end
payment_declined = false
ActiveRecord::Base.transaction do
intent = Stripe::PaymentIntent.create(
amount: order.total_cents,
currency: "usd",
payment_method: params[:payment_method_id],
confirmation_method: "manual",
confirm: true,
idempotency_key: "order-#{order.id}-pay"
)
if intent.status == "requires_payment_method"
flash.now[:alert] = intent.last_payment_error&.message || "Card declined"
payment_declined = true
raise ActiveRecord::Rollback
end
order.update!(paid: true, stripe_payment_intent_id: intent.id, paid_at: Time.current)
AuditLog.create!(user: current_user, action: "order_paid", subject: order)
end
return render :new, status: :unprocessable_entity if payment_declined
OrderMailer.paid(order).deliver_later
Analytics.track(current_user, "order_paid", order_id: order.id)
redirect_to order, notice: "Payment received"
rescue ActiveRecord::RecordNotFound
head :not_found
rescue Stripe::CardError => e
flash.now[:alert] = e.message
render :new, status: :unprocessable_entity
rescue => e
Rails.logger.error(e)
Sentry.capture_exception(e)
flash.now[:alert] = "Something went wrong"
render :new, status: :internal_server_error
end
end
- HTTP + payments + mail in one place
- hard to follow rescue branches
- copy-paste if you need it twice
- params are just "whatever showed up"
class PayOrder < CMDx::Task
required :order_id, coerce: :integer
required :payment_method_id, presence: { message: "Choose a payment method" }
required :user
output :order
on_success do
OrderMailer.paid(order).deliver_later
Analytics.track(user, "order_paid", order_id: order.id)
end
def work
skip!("Already paid") if order.paid?
ActiveRecord::Base.transaction do
fail!(payment_intent.last_payment_error&.message || "Card declined") if payment_intent.status == "requires_payment_method"
order.update!(paid: true, stripe_payment_intent_id: payment_intent.id, paid_at: Time.current)
AuditLog.create!(user: user, action: "order_paid", subject: order)
end
rescue Stripe::CardError => e
Sentry.capture_exception(e)
fail!(e.message)
end
private
def order
context.order ||= user.orders.find(order_id)
end
def payment_intent
@payment_intent ||= Stripe::PaymentIntent.create(
amount: order.total_cents,
currency: "usd",
payment_method: payment_method_id,
confirmation_method: "manual",
confirm: true,
idempotency_key: "order-#{order.id}-pay"
)
end
end
- inputs checked before
workruns - one class, one story
- call it from controllers, jobs, consoles
- same three outcomes every time
def create
result = PayOrder.execute(**payment_params, user: current_user)
if result.success?
redirect_to result.context.order, notice: "Payment received"
elsif result.skipped?
redirect_to result.context.order, notice: result.reason
else
flash.now[:alert] = result.reason || "Something went wrong"
render :new, status: :unprocessable_entity
end
end
private
def payment_params
params.permit(:order_id, :payment_method_id)
end
CERO: the same four beats, every time.
Think of it as a tiny checklist. Compose what you need, run it, branch on the answer, peek at the logs. No new vocabulary quiz required.
Compose
List the inputs you need and how they should look. Bad data stops the task before your work method even starts.
required :id, coerce: :integer
Execute
Call the same class from a controller, a background job, a rake task, or a test. One front door: execute.
Task.execute(id: 42)
React
Ask the result object what happened. Success, skipped, or failed — plain if branches, not rescue gymnastics.
result.success?
result.skipped?
result.failed?
Observe
Each run emits structured lines you can tail in dev and pipe to Datadog, Honeycomb, or whatever you like in prod.
configuration.telemetry.subscribe(...)
Start with one task. Grow into full workflows.
Flip the tabs like tiny files in an editor — same ideas, bigger scope.
class ApproveLoan < CMDx::Task
required :application_id, coerce: :integer
optional :override_checks, default: false
on_success :notify_applicant!
output :approved_at
def work
if application.nil?
fail!("Application not found", code: 404)
elsif application.approved?
skip!("Already approved")
else
application.approve!
context.approved_at = Time.current
end
end
private
def application
@application ||= LoanApplication.find_by(id: application_id)
end
def notify_applicant!
ApprovalMailer.approved(application).deliver_later
end
end
class OnboardCustomer < CMDx::Task
include CMDx::Workflow
# Run these one after another
task CreateAccount
task SetupBilling
task AssignPlan
# Fire these at the same time
tasks SendWelcomeEmail,
SendWelcomeSms,
CreateDashboard,
strategy: :parallel
# Only run this when the predicate is true
task ScheduleOnboardingCall,
if: :premium_plan?
private
def premium_plan?
context.plan.tier == :premium
end
end
# Arguments are checked before anything runs
result = ApproveLoan.execute(application_id: 42)
# Branch like normal Ruby
if result.success?
puts "Approved at #{result.context.approved_at}"
elsif result.skipped?
puts "Skipped: #{result.reason}"
elsif result.failed?
puts "Failed: #{result.reason}"
puts "Code: #{result.metadata[:code]}"
end
# Bang method raises if something goes wrong
result = OnboardCustomer.execute!(email: "jane@co.com")
# => raises CMDx::Fault on failure
# Hook your favorite metrics or tracing tool
CMDx.configure do |c|
c.telemetry.subscribe(:task_executed) do |event|
Datadog.increment("cmdx.executed", tags: [
"task:#{event.task_class}",
"status:#{event.payload[:result].status}"
])
end
end
# Each event carries ids, timing, and handy tags
# Logs show up without you wiring printf everywhere
OnboardCustomer.execute(email: "jane@co.com")
Batteries included, drama optional.
These helpers ship with the gem itself. You do not need a pile of extra dependencies just to feel productive.
Typed inputs
Say what you expect, add defaults, and validate early. If something is wrong, CMDx stops the run before work starts, so you are not debugging half-finished state.
Automatic retries
Transient network blip? Configure retries with backoff and only for the exception types you trust. Declare it once with retry_on and move on.
Workflows
Line tasks up in order, fan them out in parallel, or skip steps when a flag says so. They share one context object and errors bubble the way you expect.
Structured logging
Every run prints a tidy line: chain id, task name, state, status, runtime, and any metadata you attached. Great for grepping locally or feeding a log platform.
Middleware & callbacks
Wrap tasks when you need auth checks, caching, timing, or other cross-cutting bits. React to life-cycle moments with on_success, on_skipped, and on_failed.
I18n messages
User-facing strings ride on plain Rails I18n. English ships inside the gem; drop in YAML for any other locale when you are ready.
Real apps, same cozy pattern.
If it is important, stateful, and a little scary, it probably deserves its own task class.
Payment processing
Take money, undo charges, balance books, and leave an audit trail. Results are plain objects, and flaky gateways can opt into retries.
User onboarding
Spin up accounts, attach billing, say hello over email or SMS, and only book the fancy onboarding call when someone paid for premium.
Webhook handlers
Check signatures, route each provider to its own task, and retry safely when the internet does internet things.
Background jobs
Sidekiq (and friends) get predictable inputs, clear outcomes, and retry rules that match the exceptions you care about.
ETL & data sync
Move data in stages, save checkpoints when it makes sense, and parallelize the slow bits without losing your place.
AI agent flows
Wrap model calls, embeddings, and vector writes with timeouts, retries, and logs so you can tell what the bot actually did.