Getting Started with CMDx: Taming Business Logic in Ruby¶
I've spent years wrestling with service objects. You know the pattern—create a class, throw some business logic in a call method, cross your fingers, and hope for the best. The problem? Every team member writes them differently. Every project invents its own conventions. And when something breaks at 2 AM, good luck tracing what actually happened.
That frustration led me to create CMDx.
The Problem with Service Objects¶
Let's be honest about what we're dealing with. Your typical Rails service object looks something like this:
class ProcessOrder
def initialize(order_id, user)
@order_id = order_id
@user = user
end
def call
order = Order.find(@order_id)
return false if order.processed?
order.process!
OrderMailer.confirmation(@user).deliver_now
true
rescue => e
Rails.logger.error("Order processing failed: #{e.message}")
false
end
end
What's wrong with this? Everything and nothing. It works, but:
- Inconsistent patterns — Does it return
true/false? An object? Raises exceptions? Every service decides differently. - Black box execution — When something fails in production, you're left grepping logs hoping someone remembered to add useful output.
- Fragile error handling — That
rescue => ecatches everything. Validation errors, network timeouts, database issues—all get the same treatment.
These aren't hypothetical problems. They're the Monday morning fire drills that eat your week.
Why I Built CMDx¶
I wanted something simple enough for a junior dev to pick up in an afternoon, but powerful enough to handle complex business processes. CMDx is built around a straightforward pattern I call CERO: Compose, Execute, React, Observe.
The idea is that every piece of business logic should:
- Be composed as a self-contained task
- Execute with consistent, predictable behavior
- Return a result you can react to
- Be observable through structured logging
Let me show you what this looks like in practice.
Your First CMDx Task¶
Here's the simplest possible task:
class ProcessOrder < CMDx::Task
def work
order = Order.find(context.order_id)
order.process!
context.processed_at = Time.current
end
end
That's it. Inherit from CMDx::Task, define a work method, and you're done.
The context object is your data container—it holds everything you pass in and everything you want to pass out. No instance variables to juggle, no wondering what data is available where.
Let's execute it:
Every execution returns a Result object. Always. No exceptions (pun intended—we'll get to those).
Reacting to Outcomes¶
Here's where CMDx starts earning its keep. The result tells you exactly what happened:
result = ProcessOrder.execute(order_id: 42)
if result.success?
puts "Order processed at #{result.context.processed_at}"
elsif result.skipped?
puts "Order was skipped: #{result.reason}"
elsif result.failed?
puts "Order failed: #{result.reason}"
end
Three possible statuses: success, skipped, or failed. That's it. No mystery booleans, no exceptions to catch (unless you want them), no guessing what happened.
Controlling Execution Flow¶
Real business logic isn't a straight line. Sometimes you need to stop early, sometimes things go wrong. CMDx gives you two explicit methods for this: skip! and fail!.
Skipping: When There's Nothing to Do¶
Use skip! when the task legitimately shouldn't run. It's a no-op, not an error:
class ProcessOrder < CMDx::Task
def work
order = Order.find(context.order_id)
if order.already_processed?
skip!("Order was already processed on #{order.processed_at}")
end
order.process!
context.processed_at = Time.current
end
end
Skipped tasks are considered successful outcomes—the task did exactly what it should by recognizing there was nothing to do.
Failing: When Something Goes Wrong¶
Use fail! when the task cannot complete. This is an intentional, controlled failure:
class ProcessOrder < CMDx::Task
def work
order = Order.find_by(id: context.order_id)
if order.nil?
fail!("Order not found", code: :not_found)
elsif order.expired?
fail!("Order has expired", code: :expired, expired_at: order.expired_at)
end
order.process!
context.processed_at = Time.current
end
end
Notice the metadata I'm passing—code, expired_at. This gets captured in the result:
result = ProcessOrder.execute(order_id: 999)
if result.failed?
puts result.reason # => "Order not found"
puts result.metadata[:code] # => :not_found
end
Handling Real Exceptions¶
What about actual exceptions—database timeouts, network failures, unexpected nil values? CMDx catches these and converts them to failures automatically:
result = ProcessOrder.execute(order_id: 42)
if result.failed?
puts result.reason # => "[ActiveRecord::ConnectionError] Connection timed out"
puts result.cause # => The actual exception object
end
Your code doesn't change. The result still tells you what happened, the exception is still available if you need it for debugging, but your calling code doesn't need a rescue block.
Observing Everything¶
This is my favorite part. Every CMDx execution automatically logs what happened:
I, [2025-01-07T14:32:15.000000 #3784] INFO -- CMDx:
index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="ProcessOrder" state="complete" status="success" metadata={runtime: 47}
Every execution. Automatically. With timing, chain correlation, and outcome status.
When something fails:
I, [2025-01-07T14:32:17.000000 #3784] INFO -- CMDx:
index=0 chain_id="018c2b95-c921-8834-b234-dd6c721fe3a7" type="Task" class="ProcessOrder" state="interrupted" status="failed" metadata={code: :not_found} reason="Order not found"
You can also log from within your task:
class ProcessOrder < CMDx::Task
def work
logger.debug { "Looking up order #{context.order_id}" }
order = Order.find(context.order_id)
order.process!
logger.info "Order #{order.id} processed successfully"
context.processed_at = Time.current
end
end
What's Next¶
This introduction covers the foundation—tasks, execution, halting, and observability. But CMDx has more to offer:
- Attributes with type coercion and validation
- Callbacks for cross-cutting concerns
- Workflows for orchestrating multi-step processes
- Middlewares for wrapping execution with custom behavior
- Retries for handling transient failures
Check out the full documentation to explore these features.
The goal of CMDx is simple: make your business logic predictable, observable, and maintainable. No more 2 AM mysteries. No more inconsistent patterns. Just clean, focused tasks that tell you exactly what they did.
Give it a try! I'd love to hear how it works for you.