# CMDx > Build business logic that's powerful, predictable, and maintainable. CMDx is a Ruby framework for building maintainable, observable business logic through composable command/service objects. It brings structure, consistency, and powerful developer tools to your business processes. # Getting Started # CMDx Build business logic that's powerful, predictable, and maintainable. ______________________________________________________________________ Say goodbye to messy service objects. CMDx (pronounced "Command X") helps you design business logic with clarity and consistency—build faster, debug easier, and ship with confidence. Note Documentation reflects the latest code on `main`. For version-specific documentation, please refer to the `docs/` directory within that version's tag. ## Requirements - Ruby: MRI 3.1+ or JRuby 9.4+. CMDx works with any Ruby framework. Rails support is built-in, but it's framework-agnostic at its core. ## Installation ```sh gem install cmdx # - or - bundle add cmdx ``` ## Quick Example Build powerful business logic in four simple steps: ### 1. Compose ```ruby class AnalyzeMetrics < CMDx::Task register :middleware, CMDx::Middlewares::Correlate, id: -> { Current.request_id } on_success :track_analysis_completion! required :dataset_id, type: :integer, numeric: { min: 1 } optional :analysis_type, default: "standard" def work if dataset.nil? fail!("Dataset not found", code: 404) elsif dataset.unprocessed? skip!("Dataset not ready for analysis") else context.result = PValueAnalyzer.execute(dataset:, analysis_type:) context.analyzed_at = Time.now SendAnalyzedEmail.execute(user_id: Current.account.manager_id) end end private def dataset @dataset ||= Dataset.find_by(id: dataset_id) end def track_analysis_completion! dataset.update!(analysis_result_id: context.result.id) end end ``` ```ruby class SendAnalyzedEmail < CMDx::Task def work user = User.find(context.user_id) MetricsMailer.analyzed(user).deliver_now end end ``` ### 2. Execute ```ruby result = AnalyzeMetrics.execute( dataset_id: 123, "analysis_type" => "advanced" ) ``` ### 3. React ```ruby if result.success? puts "Metrics analyzed at #{result.context.analyzed_at}" elsif result.skipped? puts "Skipping analyzation due to: #{result.reason}" elsif result.failed? puts "Analyzation failed due to: #{result.reason} with code #{result.metadata[:code]}" end ``` ### 4. Observe ```text I, [2022-07-17T18:42:37.000000 #3784] INFO -- CMDx: index=1 chain_id="018c2b95-23j4-2kj3-32kj-3n4jk3n4jknf" type="Task" class="SendAnalyzedEmail" state="complete" status="success" metadata={runtime: 347} I, [2022-07-17T18:43:15.000000 #3784] INFO -- CMDx: index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="AnalyzeMetrics" state="complete" status="success" metadata={runtime: 187} ``` Ready to dive in? Check out the [Getting Started](getting_started/) guide to learn more. ## Ecosystem - [cmdx-rspec](https://github.com/drexed/cmdx-rspec) - RSpec test matchers For backwards compatibility of certain functionality: - [cmdx-i18n](https://github.com/drexed/cmdx-i18n) - 85+ translations, `v1.5.0` - `v1.6.2` - [cmdx-parallel](https://github.com/drexed/cmdx-parallel) - Parallel workflow tasks, `v1.6.1` - `v1.6.2` ## Contributing Bug reports and pull requests are welcome at . We're committed to fostering a welcoming, collaborative community. Please follow our [code of conduct](https://github.com/drexed/cmdx/blob/main/CODE_OF_CONDUCT.md). ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). # 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:** - Inconsistent service object patterns across your codebase - Black boxes make 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 ## Installation Add CMDx to your Gemfile: ```sh gem install cmdx # - or - bundle add cmdx ``` ## Configuration For Rails applications, run the following command to generate a global configuration file in `config/initializers/cmdx.rb`. ```bash rails generate cmdx:install ``` If not using Rails, manually copy the [configuration file](https://github.com/drexed/cmdx/blob/main/lib/generators/cmdx/templates/install.rb). ## The CERO Pattern CMDx embraces the Compose, Execute, React, Observe (CERO, pronounced "zero") pattern—a simple yet powerful approach to building reliable business logic. ### Compose Build reusable, single-responsibility tasks with typed attributes, validation, and callbacks. Tasks can be chained together in workflows to create complex business processes from simple building blocks. ```ruby class AnalyzeMetrics < CMDx::Task def work # Your logic here... end end ``` ### Execute Invoke tasks with a consistent API that always returns a result object. Execution automatically handles validation, type coercion, error handling, and logging. Arguments are validated and coerced before your task logic runs. ```ruby # Without args result = AnalyzeMetrics.execute # With args result = AnalyzeMetrics.execute(model: "blackbox", "sensitivity" => 3) ``` ### React Every execution returns a result object with a clear outcome. Check the result's state (`success?`, `failed?`, `skipped?`) and access returned values, error messages, and metadata to make informed decisions. ```ruby if result.success? # Handle success elsif result.skipped? # Handle skipped elsif result.failed? # Handle failed end ``` ### Observe Every task execution generates structured logs with execution chains, runtime metrics, and contextual metadata. Logs can be automatically correlated using chain IDs, making it easy to trace complex workflows and debug issues. ```text I, [2022-07-17T18:42:37.000000 #3784] INFO -- CMDx: index=1 chain_id="018c2b95-23j4-2kj3-32kj-3n4jk3n4jknf" type="Task" class="SendAnalyzedEmail" state="complete" status="success" metadata={runtime: 347} I, [2022-07-17T18:43:15.000000 #3784] INFO -- CMDx: index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="AnalyzeMetrics" state="complete" status="success" metadata={runtime: 187} ``` ## Task Generator Generate new CMDx tasks quickly using the built-in generator: ```bash rails generate cmdx:task ModerateBlogPost ``` This creates a new task file with the basic structure: ```ruby # 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 # Configuration Configure CMDx to customize framework behavior, register components, and control execution flow through global defaults with task-level overrides. ## Configuration Hierarchy CMDx uses a straightforward two-tier configuration system: 1. **Global Configuration** — Framework-wide defaults 1. **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. ```ruby CMDx.configure do |config| config.task_breakpoints = "failed" # String or Array[String] end ``` For workflows, configure which statuses halt the execution pipeline: ```ruby CMDx.configure do |config| config.workflow_breakpoints = ["skipped", "failed"] end ``` ### Rollback Control when a `rollback` of task execution is called. ```ruby CMDx.configure do |config| config.rollback_on = ["failed"] # String or Array[String] end ``` ### 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`. ```ruby 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. ```ruby 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 ```ruby CMDx.configure do |config| config.logger = CustomLogger.new($stdout) end ``` ### Middlewares See the [Middlewares](../middlewares/#declarations) docs for task level configurations. ```ruby 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](../callbacks/#declarations) docs for task level configurations. ```ruby 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](../attributes/coercions/#declarations) docs for task level configurations. ```ruby 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](../attributes/validations/#declarations) docs for task level configurations. ```ruby 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`: ```ruby 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 rollback_on: ["failed", "skipped"], # Rollback on override ) def work # Your logic here... end end ``` Important Retries reuse the same context. By default, all `StandardError` exceptions (including faults) are retried unless you specify `retry_on` option for specific matches. ### Registrations Register or deregister middlewares, callbacks, coercions, and validators for specific tasks: ```ruby 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 ```ruby # Global configuration access CMDx.configuration.logger #=> CMDx.configuration.task_breakpoints #=> ["failed"] CMDx.configuration.middlewares.registry #=> [, ...] # 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. ```ruby # 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 ``` # Basics # Basics - Setup Tasks are the heart of CMDx—self-contained units of business logic with built-in validation, error handling, and execution tracking. ## Structure Tasks need only two things: inherit from `CMDx::Task` and define a `work` method: ```ruby class ValidateDocument < CMDx::Task def work # Your logic here... end end ``` Without a `work` method, execution raises `CMDx::UndefinedMethodError`. ```ruby class IncompleteTask < CMDx::Task # No `work` method defined end IncompleteTask.execute #=> raises CMDx::UndefinedMethodError ``` ## Rollback Undo any operations linked to the given status, helping to restore a pristine state. ```ruby class ValidateDocument < CMDx::Task def work # Your logic here... end def rollback # Your undo logic... end end ``` ## Inheritance Share configuration across tasks using inheritance: ```ruby class ApplicationTask < CMDx::Task register :middleware, SecurityMiddleware before_execution :initialize_request_tracking attribute :session_id private def initialize_request_tracking context.tracking_id ||= SecureRandom.uuid end end class SyncInventory < ApplicationTask def work # Your logic here... end end ``` ## Lifecycle Tasks follow a predictable execution pattern: Caution Tasks are single-use objects. Once executed, they're frozen and immutable. | Stage | State | Status | Description | | ----------------- | ------------- | ---------------------------- | ------------------------- | | **Instantiation** | `initialized` | `success` | Task created with context | | **Validation** | `executing` | `success`/`failed` | Attributes validated | | **Execution** | `executing` | `success`/`failed`/`skipped` | `work` method runs | | **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized | | **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable | | **Rollback** | `executed` | `failed`/`skipped` | Work undone | # Basics - Execution CMDx offers two execution methods with different error handling approaches. Choose based on your needs: safe result handling or exception-based control flow. ## Execution Methods Both methods return results, but handle failures differently: | Method | Returns | Exceptions | Use Case | | ---------- | --------------------------------- | ------------------------------------------- | ---------------------------- | | `execute` | Always returns `CMDx::Result` | Never raises | Predictable result handling | | `execute!` | Returns `CMDx::Result` on success | Raises `CMDx::Fault` when skipped or failed | Exception-based control flow | ## Non-bang Execution Always returns a `CMDx::Result`, never raises exceptions. Perfect for most use cases. ```ruby result = CreateAccount.execute(email: "user@example.com") # Check execution state result.success? #=> true/false result.failed? #=> true/false result.skipped? #=> true/false # Access result data result.context.email #=> "user@example.com" result.state #=> "complete" result.status #=> "success" ``` ## Bang Execution Raises `CMDx::Fault` exceptions on failure or skip. Returns results only on success. | Exception | Raised When | | ----------------- | ------------------------- | | `CMDx::FailFault` | Task execution fails | | `CMDx::SkipFault` | Task execution is skipped | Important Behavior depends on `task_breakpoints` or `workflow_breakpoints` config. Default: only failures raise exceptions. ```ruby begin result = CreateAccount.execute!(email: "user@example.com") SendWelcomeEmail.execute(result.context) rescue CMDx::FailFault => e ScheduleAccountRetryJob.perform_later(e.result.context.email) rescue CMDx::SkipFault => e Rails.logger.info("Account creation skipped: #{e.result.reason}") rescue Exception => e ErrorTracker.capture(unhandled_exception: e) end ``` ## Direct Instantiation Tasks can be instantiated directly for advanced use cases, testing, and custom execution patterns: ```ruby # Direct instantiation task = CreateAccount.new(email: "user@example.com", send_welcome: true) # Access properties before execution task.id #=> "abc123..." (unique task ID) task.context.email #=> "user@example.com" task.context.send_welcome #=> true task.result.state #=> "initialized" task.result.status #=> "success" # Manual execution task.execute # or task.execute! task.result.success? #=> true/false ``` ## Result Details The `Result` object provides comprehensive execution information: ```ruby result = CreateAccount.execute(email: "user@example.com") # Execution metadata result.id #=> "abc123..." (unique execution ID) result.task #=> CreateAccount instance (frozen) result.chain #=> Task execution chain # Context and metadata result.context #=> Context with all task data result.metadata #=> Hash with execution metadata ``` # Basics - Context Context is your data container for inputs, intermediate values, and outputs. It makes sharing data between tasks effortless. ## Assigning Data Context automatically captures all task inputs, normalizing keys to symbols: ```ruby # Direct execution CalculateShipping.execute(weight: 2.5, destination: "CA") # Instance creation CalculateShipping.new(weight: 2.5, "destination" => "CA") ``` Important String keys convert to symbols automatically. Prefer symbols for consistency. ## Accessing Data Access context data using method notation, hash keys, or safe accessors: ```ruby class CalculateShipping < CMDx::Task def work # Method style access (preferred) weight = context.weight destination = context.destination # Hash style access service_type = context[:service_type] options = context["options"] # Safe access with defaults rush_delivery = context.fetch!(:rush_delivery, false) carrier = context.dig(:options, :carrier) # Shorter alias cost = ctx.weight * ctx.rate_per_pound # ctx aliases context end end ``` Important Undefined attributes return `nil` instead of raising errors—perfect for optional data. ## Modifying Context Context supports dynamic modification during task execution: ```ruby class CalculateShipping < CMDx::Task def work # Direct assignment context.carrier = Carrier.find_by(code: context.carrier_code) context.package = Package.new(weight: context.weight) context.calculated_at = Time.now # Hash-style assignment context[:status] = "calculating" context["tracking_number"] = "SHIP#{SecureRandom.hex(6)}" # Conditional assignment context.insurance_included ||= false # Batch updates context.merge!( status: "completed", shipping_cost: calculate_cost, estimated_delivery: Time.now + 3.days ) # Remove sensitive data context.delete!(:credit_card_token) end private def calculate_cost base_rate = context.weight * context.rate_per_pound base_rate + (base_rate * context.tax_percentage) end end ``` Tip Use context for both input values and intermediate results. This creates natural data flow through your task execution pipeline. ## Data Sharing Share context across tasks for seamless data flow: ```ruby # During execution class CalculateShipping < CMDx::Task def work # Validate shipping data validation_result = ValidateAddress.execute(context) # Via context CalculateInsurance.execute(context) # Via result NotifyShippingCalculated.execute(validation_result) # Context now contains accumulated data from all tasks context.address_validated #=> true (from validation) context.insurance_calculated #=> true (from insurance) context.notification_sent #=> true (from notification) end end # After execution result = CalculateShipping.execute(destination: "New York, NY") CreateShippingLabel.execute(result) ``` # Basics - Chain Chains automatically track related task executions within a thread. Think of them as execution traces that help you understand what happened and in what order. ## Management Each thread maintains its own isolated chain using thread-local storage. Warning Chains are thread-local. Don't share chain references across threads—it causes race conditions. ```ruby # Thread A Thread.new do result = ImportDataset.execute(file_path: "/data/batch1.csv") result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" end # Thread B (completely separate chain) Thread.new do result = ImportDataset.execute(file_path: "/data/batch2.csv") result.chain.id #=> "z3a42b95-c821-7892-b156-dd7c921fe2a3" end # Access current thread's chain CMDx::Chain.current #=> Returns current chain or nil CMDx::Chain.clear #=> Clears current thread's chain ``` ## Links Tasks automatically create or join the current thread's chain: Important Chain management is automatic—no manual lifecycle handling needed. ```ruby class ImportDataset < CMDx::Task def work # First task creates new chain result1 = ValidateHeaders.execute(file_path: context.file_path) result1.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" result1.chain.results.size #=> 1 # Second task joins existing chain result2 = SendNotification.execute(to: "admin@company.com") result2.chain.id == result1.chain.id #=> true result2.chain.results.size #=> 2 # Both results reference the same chain result1.chain.results == result2.chain.results #=> true end end ``` ## Inheritance Subtasks automatically inherit the current thread's chain, building a unified execution trail: ```ruby class ImportDataset < CMDx::Task def work context.dataset = Dataset.find(context.dataset_id) # Subtasks automatically inherit current chain ValidateSchema.execute TransformData.execute!(context) SaveToDatabase.execute(dataset_id: context.dataset_id) end end result = ImportDataset.execute(dataset_id: 456) chain = result.chain # All tasks share the same chain chain.results.size #=> 4 (main task + 3 subtasks) chain.results.map { |r| r.task.class } #=> [ImportDataset, ValidateSchema, TransformData, SaveToDatabase] ``` ## Structure Chains expose comprehensive execution information: Important Chain state reflects the first (outermost) task result. Subtasks maintain their own states. ```ruby result = ImportDataset.execute(dataset_id: 456) chain = result.chain # Chain identification chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5" chain.results #=> Array of all results in execution order # State delegation (from first/outer-most result) chain.state #=> "complete" chain.status #=> "success" chain.outcome #=> "success" # Access individual results chain.results.each_with_index do |result, index| puts "#{index}: #{result.task.class} - #{result.status}" end ``` # Interruptions # Interruptions - Halt Stop task execution intentionally using `skip!` or `fail!`. Both methods signal clear intent about why execution stopped. ## Skipping Use `skip!` when the task doesn't need to run. It's a no-op, not an error. Important Skipped tasks are considered "good" outcomes—they succeeded by doing nothing. ```ruby class ProcessInventory < CMDx::Task def work # Without a reason skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name) # With a reason skip!("Warehouse closed") unless Time.now.hour.between?(8, 18) inventory = Inventory.find(context.inventory_id) if inventory.already_counted? skip!("Inventory already counted today") else inventory.count! end end end result = ProcessInventory.execute(inventory_id: 456) # Executed result.status #=> "skipped" # Without a reason result.reason #=> "Unspecified" # With a reason result.reason #=> "Warehouse closed" ``` ## Failing Use `fail!` when the task can't complete successfully. It signals controlled, intentional failure: ```ruby class ProcessRefund < CMDx::Task def work # Without a reason fail! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name) refund = Refund.find(context.refund_id) # With a reason if refund.expired? fail!("Refund period has expired") elsif !refund.amount.positive? fail!("Refund amount must be positive") else refund.process! end end end result = ProcessRefund.execute(refund_id: 789) # Executed result.status #=> "failed" # Without a reason result.reason #=> "Unspecified" # With a reason result.reason #=> "Refund period has expired" ``` ## Metadata Enrichment Enrich halt calls with metadata for better debugging and error handling: ```ruby class ProcessRenewal < CMDx::Task def work license = License.find(context.license_id) if license.already_renewed? # Without metadata skip!("License already renewed") end unless license.renewal_eligible? # With metadata fail!( "License not eligible for renewal", error_code: "LICENSE.NOT_ELIGIBLE", retry_after: Time.current + 30.days ) end process_renewal end end result = ProcessRenewal.execute(license_id: 567) # Without metadata result.metadata #=> {} # With metadata result.metadata #=> { # error_code: "LICENSE.NOT_ELIGIBLE", # retry_after: