Messy workflows,
clear outcomes.
A framework for making composable Ruby service objects perform predictably.
One class, one call, one outcome.
gem install cmdx
Business logic sprawls. Nothing owns the outcome.
Paying an order might live in a controller, a job, and a service object — similar steps, slightly different rules, no shared boundary. Important workflows never get a single front door.
- The same flow reimplemented at every entry point
- Rules and side effects scattered with no clear encapsulation
- Hard to test without HTTP, queues, or the whole stack
- Success, skip, and failure mean something different per caller
- Idempotency, retries, and guards reinvented in each copy
- Changing the rules means hunting down every duplicate
- No single place explains how a workflow behaves end to end
# payments#create, PayOrderJob#perform, Orders::PaymentService…
def charge_and_complete(order, payment_method_id)
return if order.paid? # skip? fail? depends who called
raise ArgumentError if payment_method_id.blank? # sometimes validated upstream
payment_declined = false
ActiveRecord::Base.transaction do
intent = Stripe::PaymentIntent.create(
amount: order.total_cents, currency: "usd",
payment_method: payment_method_id, confirm: true,
idempotency_key: "order-#{order.id}-pay" # only in one of the three copies
)
if intent.status == "requires_payment_method"
payment_declined = true
raise ActiveRecord::Rollback
end
order.update!(paid: true, stripe_payment_intent_id: intent.id)
AuditLog.create!(action: "order_paid", subject: order)
end
return false if payment_declined # controller renders; job retries?
OrderMailer.paid(order).deliver_later
Analytics.track("order_paid", order_id: order.id)
true
rescue Stripe::CardError => e
raise e # or flash, or return false…
rescue => e
Sentry.capture_exception(e); raise
end
CMDx encapsulates each workflow in a task class: validated inputs, one work method, one Result. Any caller uses execute and branches on success?, skipped?, or failed? — the demo below walks through the full pattern.
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, another workflow, 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.
Four tabs, one story: define a task, chain a workflow, run it, read the logs.
class ApproveLoan < CMDx::Task
required :application_id, coerce: :integer
output :approved_at
on_success :notify_applicant!
def work
fail!("Application not found", code: 404) if application.nil?
skip!("Already approved") if application.approved?
application.approve!
context.approved_at = Time.current
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
task CreateAccount
task SetupBilling
task AssignPlan
tasks SendWelcomeEmail, SendWelcomeSms, strategy: :parallel
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
# 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.
Distributed locks
Two workers, same key? lock_with grabs a short lease in your store so only one run is in the room at a time — then the next job is welcome once it finishes.
Circuit breaker
When a dependency is clearly down, stop paying for timeouts. circuit_breaker counts failures, opens the circuit, and fails fast until the cool-off passes.
Idempotency
Webhook redelivery or a retry mid-flight should not double-charge. idempotent_by claims a stable key and skips duplicate runs before work starts.
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.