Getting Started¶
CMDx is a Ruby framework for building maintainable, observable business logic through composable command objects. It brings structure, consistency, and powerful developer tools to your business processes.
Common challenges it solves:
- Inconsistent service object patterns across your codebase
- Limited logging makes debugging a nightmare
- Fragile error handling erodes confidence
What you get:
- Consistent, standardized architecture
- Built-in flow control and error handling
- Composable, reusable workflows
- Comprehensive logging for observability
- Attribute validation with type coercions
- Sensible defaults and developer-friendly APIs
The CERO Pattern¶
CMDx embraces the Compose, Execute, React, Observe (CERO) patternβa simple yet powerful approach to building reliable business logic.
π§© Compose β Define small, focused tasks with typed attributes and validations
β‘ Execute β Run tasks with clear outcomes and pluggable behaviors
π React β Adapt to outcomes by chaining follow-up tasks or handling faults
π Observe β Capture structured logs and execution chains for debugging
Installation¶
Add CMDx to your Gemfile:
For Rails applications, generate the configuration:
This creates config/initializers/cmdx.rb
file.
Configuration Hierarchy¶
CMDx uses a straightforward two-tier configuration system:
- Global Configuration β Framework-wide defaults
- Task Settings β Class-level overrides using
settings
Important
Task settings take precedence over global config. Settings are inherited from parent classes and can be overridden in subclasses.
Global Configuration¶
Configure framework-wide defaults that apply to all tasks. These settings come with sensible defaults out of the box.
Breakpoints¶
Control when execute!
raises a CMDx::Fault
based on task status.
For workflows, configure which statuses halt the execution pipeline:
Backtraces¶
Enable detailed backtraces for non-fault exceptions to improve debugging. Optionally clean up stack traces to remove framework noise.
Note
In Rails environments, backtrace_cleaner
defaults to Rails.backtrace_cleaner.clean
.
CMDx.configure do |config|
# Truthy
config.backtrace = true
# Via callable (must respond to `call(backtrace)`)
config.backtrace_cleaner = AdvanceCleaner.new
# Via proc or lambda
config.backtrace_cleaner = ->(backtrace) { backtrace[0..5] }
end
Exception Handlers¶
Register handlers that run when non-fault exceptions occur.
Tip
Use exception handlers to send errors to your APM of choice.
CMDx.configure do |config|
# Via callable (must respond to `call(task, exception)`)
config.exception_handler = NewRelicReporter
# Via proc or lambda
config.exception_handler = proc do |task, exception|
APMService.report(exception, extra_data: { task: task.name, id: task.id })
end
end
Logging¶
Middlewares¶
See the Middlewares docs for task level configurations.
CMDx.configure do |config|
# Via callable (must respond to `call(task, options)`)
config.middlewares.register CMDx::Middlewares::Timeout
# Via proc or lambda
config.middlewares.register proc { |task, options|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = yield
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
Rails.logger.debug { "task completed in #{((end_time - start_time) * 1000).round(2)}ms" }
result
}
# With options
config.middlewares.register AuditTrailMiddleware, service_name: "document_processor"
# Remove middleware
config.middlewares.deregister CMDx::Middlewares::Timeout
end
Note
Middlewares are executed in registration order. Each middleware wraps the next, creating an execution chain around task logic.
Callbacks¶
See the Callbacks docs for task level configurations.
CMDx.configure do |config|
# Via method
config.callbacks.register :before_execution, :initialize_user_session
# Via callable (must respond to `call(task)`)
config.callbacks.register :on_success, LogUserActivity
# Via proc or lambda
config.callbacks.register :on_complete, proc { |task|
execution_time = task.metadata[:runtime]
Metrics.timer("task.execution_time", execution_time, tags: ["task:#{task.class.name.underscore}"])
}
# With options
config.callbacks.register :on_failure, :send_alert_notification, if: :critical_task?
# Remove callback
config.callbacks.deregister :on_success, LogUserActivity
end
Coercions¶
See the Attributes - Coercions docs for task level configurations.
CMDx.configure do |config|
# Via callable (must respond to `call(value, options)`)
config.coercions.register :currency, CurrencyCoercion
# Via method (must match signature `def coordinates_coercion(value, options)`)
config.coercions.register :coordinates, :coordinates_coercion
# Via proc or lambda
config.coercions.register :tag_list, proc { |value, options|
delimiter = options[:delimiter] || ','
max_tags = options[:max_tags] || 50
tags = value.to_s.split(delimiter).map(&:strip).reject(&:empty?)
tags.first(max_tags)
}
# Remove coercion
config.coercions.deregister :currency
end
Validators¶
See the Attributes - Validations docs for task level configurations.
CMDx.configure do |config|
# Via callable (must respond to `call(value, options)`)
config.validators.register :username, UsernameValidator
# Via method (must match signature `def url_validator(value, options)`)
config.validators.register :url, :url_validator
# Via proc or lambda
config.validators.register :access_token, proc { |value, options|
expected_prefix = options[:prefix] || "tok_"
minimum_length = options[:min_length] || 40
value.start_with?(expected_prefix) && value.length >= minimum_length
}
# Remove validator
config.validators.deregister :username
end
Task Configuration¶
Settings¶
Override global configuration for specific tasks using settings
:
class GenerateInvoice < CMDx::Task
settings(
# Global configuration overrides
task_breakpoints: ["failed"], # Breakpoint override
workflow_breakpoints: [], # Breakpoint override
backtrace: true, # Toggle backtrace
backtrace_cleaner: ->(bt) { bt[0..5] }, # Backtrace cleaner
logger: CustomLogger.new($stdout), # Custom logger
# Task configuration settings
breakpoints: ["failed"], # Contextual pointer for :task_breakpoints and :workflow_breakpoints
log_level: :info, # Log level override
log_formatter: CMDx::LogFormatters::Json.new # Log formatter override
tags: ["billing", "financial"], # Logging tags
deprecated: true, # Task deprecations
retries: 3, # Non-fault exception retries
retry_on: [External::ApiError], # List of exceptions to retry on
retry_jitter: 1 # Space between retry iteration, eg: current retry num + 1
)
def work
# Your logic here...
end
end
Important
Retries reuse the same context. By default, all StandardError
exceptions are retried unless you specify retry_on
.
Registrations¶
Register or deregister middlewares, callbacks, coercions, and validators for specific tasks:
class SendCampaignEmail < CMDx::Task
# Middlewares
register :middleware, CMDx::Middlewares::Timeout
deregister :middleware, AuditTrailMiddleware
# Callbacks
register :callback, :on_complete, proc { |task|
runtime = task.metadata[:runtime]
Analytics.track("email_campaign.sent", runtime, tags: ["task:#{task.class.name}"])
}
deregister :callback, :before_execution, :initialize_user_session
# Coercions
register :coercion, :currency, CurrencyCoercion
deregister :coercion, :coordinates
# Validators
register :validator, :username, :username_validator
deregister :validator, :url
def work
# Your logic here...
end
end
Configuration Management¶
Access¶
# Global configuration access
CMDx.configuration.logger #=> <Logger instance>
CMDx.configuration.task_breakpoints #=> ["failed"]
CMDx.configuration.middlewares.registry #=> [<Middleware>, ...]
# Task configuration access
class ProcessUpload < CMDx::Task
settings(tags: ["files", "storage"])
def work
self.class.settings[:logger] #=> Global configuration value
self.class.settings[:tags] #=> Task configuration value => ["files", "storage"]
end
end
Resetting¶
Warning
Resetting affects your entire application. Use this primarily in test environments.
# Reset to framework defaults
CMDx.reset_configuration!
# Verify reset
CMDx.configuration.task_breakpoints #=> ["failed"] (default)
CMDx.configuration.middlewares.registry #=> Empty registry
# Commonly used in test setup (RSpec example)
RSpec.configure do |config|
config.before(:each) do
CMDx.reset_configuration!
end
end
Task Generator¶
Generate new CMDx tasks quickly using the built-in generator:
This creates a new task file with the basic structure:
# app/tasks/moderate_blog_post.rb
class ModerateBlogPost < CMDx::Task
def work
# Your logic here...
end
end
Tip
Use present tense verbs + noun for task names, eg: ModerateBlogPost
, ScheduleAppointment
, ValidateDocument
Type safety¶
CMDx includes built-in RBS (Ruby Type Signature) inline annotations throughout the codebase, providing type information for static analysis and editor support.
- Type checking β Catch type errors before runtime using tools like Steep or TypeProf
- Better IDE support β Enhanced autocomplete, navigation, and inline documentation
- Self-documenting code β Clear method signatures and return types
- Refactoring confidence β Type-aware refactoring reduces bugs