v2.0 · Runtime Signals

Wild logic,
tamed objects.

A framework for making service-style Ruby feel predictable: define the work, execute the task, handle the result

$ gem install cmdx
· 01 · the before / after

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.

today: everything in one action
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"
with cmdx: one job, one class
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 work runs
  • one class, one story
  • call it from controllers, jobs, consoles
  • same three outcomes every time
your controller stays boring (on purpose)
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
· 02 · the cheat code

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.

01 · C

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
02 · E

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)
03 · R

React

Ask the result object what happened. Success, skipped, or failed — plain if branches, not rescue gymnastics.

result.success?
result.skipped?
result.failed?
04 · O

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(...)
· 03 · click around

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")
· 04 · the good stuff

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.

· 05 · where it shines

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.

stripebraintreeledgers

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.

signupbillingwelcome

Webhook handlers

Check signatures, route each provider to its own task, and retry safely when the internet does internet things.

stripegithubtwilio

Background jobs

Sidekiq (and friends) get predictable inputs, clear outcomes, and retry rules that match the exceptions you care about.

sidekiqasync-jobsolid_queue

ETL & data sync

Move data in stages, save checkpoints when it makes sense, and parallelize the slow bits without losing your place.

extracttransformload

AI agent flows

Wrap model calls, embeddings, and vector writes with timeouts, retries, and logs so you can tell what the bot actually did.

openaitoolsvectors