# CMDx Documentation This file contains all the CMDx documentation consolidated from the docs directory. --- url: https://github.com/drexed/cmdx/blob/main/docs/getting_started.md --- # Getting Started CMDx is a Ruby framework for building maintainable, observable business logic through composable command objects. Design robust workflows with automatic attribute validation, structured error handling, comprehensive logging, and intelligent execution flow control. **Common Challenges:** - Inconsistent patterns across implementations - Minimal or no logging, making debugging painful - Fragile designs that erode developer confidence **CMDx Solutions:** - Establishes a consistent, standardized design - Provides flow control and error handling - Supports composable, reusable workflows - Includes detailed logging for observability - Defines input attributes with fallback defaults - Adds validations and type coercions - Plus many other developer-friendly tools ## Compose, Execute, React, Observe pattern CMDx encourages breaking business logic into composable tasks. Each task can be combined into larger workflows, executed with standardized flow control, and fully observed through logging, validations, and context. - **Compose** → Define small, contract-driven tasks with typed attributes, validations, and natural workflow composition. - **Execute** → Run tasks with clear outcomes, intentional halts, and pluggable behaviors via middlewares and callbacks. - **React** → Adapt to outcomes by chaining follow-up tasks, handling faults, or shaping future flows. - **Observe** → Capture immutable results, structured logs, and full execution chains for reliable tracing and insight. ## Installation Add CMDx to your Gemfile: ```ruby gem 'cmdx' ``` For Rails applications, generate the configuration: ```bash rails generate cmdx:install ``` This creates `config/initializers/cmdx.rb` file. ## Configuration Hierarchy CMDx follows a two-tier configuration hierarchy: 1. **Global Configuration**: Framework-wide defaults 2. **Task Settings**: Class-level overrides via `settings` > [!IMPORTANT] > Task-level settings take precedence over global configuration. Settings are inherited from superclasses and can be overridden in subclasses. ## Global Configuration Global configuration settings apply to all tasks inherited from `CMDx::Task`. Globally these settings are initialized with sensible defaults. ### Breakpoints Raise `CMDx::Fault` when a task called with `execute!` returns a matching status. ```ruby CMDx.configure do |config| # String or Array[String] config.task_breakpoints = "failed" end ``` Workflow breakpoints stops execution and of workflow pipeline on the first task that returns a matching status and throws its `CMDx::Fault`. ```ruby CMDx.configure do |config| # String or Array[String] config.workflow_breakpoints = ["skipped", "failed"] end ``` ### Backtraces Enable backtraces to be logged on any non-fault exceptions for improved debugging context. Run them through a cleaner to remove unwanted stack trace noise. > [!NOTE] > The `backtrace_cleaner` is set to `Rails.backtrace_cleaner.clean` in a Rails env by default. ```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 Use exception handlers are called on non-fault standard error based exceptions. > [!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 [Middelwares](#https://github.com/drexed/cmdx/blob/main/docs/middlewares.md#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](#https://github.com/drexed/cmdx/blob/main/docs/callbacks.md#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](#https://github.com/drexed/cmdx/blob/main/docs/attributes/coercions.md#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](#https://github.com/drexed/cmdx/blob/main/docs/attributes/validations.md#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 ) def work # Your logic here... end end ``` > [!IMPORTANT] > Retries reuse the same context when executing its work. By default all `StandardErrors` will be retried if no `retry_on` option is passed. ### Registrations Register middlewares, callbacks, coercions, and validators on a specific task. Deregister options that should not be available. ```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 configuration affects the entire application. Use primarily in test environments or during application initialization. ```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 ``` ## 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 --- url: https://github.com/drexed/cmdx/blob/main/docs/basics/setup.md --- # Basics - Setup Tasks are the core building blocks of CMDx, encapsulating business logic within structured, reusable objects. Each task represents a unit of work with automatic attribute validation, error handling, and execution tracking. ## Structure Tasks inherit from `CMDx::Task` and require only a `work` method: ```ruby class ValidateDocument < CMDx::Task def work # Your logic here... end end ``` An exception will be raised if a work method is not defined. ```ruby class IncompleteTask < CMDx::Task # No `work` method defined end IncompleteTask.execute #=> raises CMDx::UndefinedMethodError ``` ## Inheritance All configuration options are inheritable by any child classes. Create a base class to share common configuration across tasks: ```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 call pattern with specific states and statuses: > [!CAUTION] > Tasks are single-use objects. Once executed, they are frozen and cannot be executed again. | 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 | --- url: https://github.com/drexed/cmdx/blob/main/docs/basics/execution.md --- # Basics - Execution Task execution in CMDx provides two distinct methods that handle success and halt scenarios differently. Understanding when to use each method is crucial for proper error handling and control flow in your application workflows. ## Methods Overview Tasks are single-use objects. Once executed, they are frozen and cannot be executed again. Create a new instance for subsequent executions. | 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 The `execute` method always returns a `CMDx::Result` object regardless of execution outcome. This is the preferred method for most use cases. Any unhandled exceptions will be caught and returned as a task failure. ```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 The bang `execute!` method raises a `CMDx::Fault` based exception when tasks fail or are skipped, and returns a `CMDx::Result` object only on success. It raises any unhandled non-fault exceptions caused during execution. | Exception | Raised When | |-----------|-------------| | `CMDx::FailFault` | Task execution fails | | `CMDx::SkipFault` | Task execution is skipped | > [!IMPORTANT] > `execute!` behavior depends on the `task_breakpoints` or `workflow_breakpoints` configuration. By default, it raises exceptions only on failures. ```ruby begin result = CreateAccount.execute!(email: "user@example.com") SendWelcomeEmail.execute(result.context) rescue CMDx::Fault => 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" 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 --- url: https://github.com/drexed/cmdx/blob/main/docs/basics/context.md --- # Basics - Context Task context provides flexible data storage, access, and sharing within task execution. It serves as the primary data container for all task inputs, intermediate results, and outputs. ## Assigning Data Context is automatically populated with all inputs passed to a task. All keys are normalized to symbols for consistent access: ```ruby # Direct execution CalculateShipping.execute(weight: 2.5, destination: "CA") # Instance creation CalculateShipping.new(weight: 2.5, "destination" => "CA") ``` > [!IMPORTANT] > String keys are automatically converted to symbols. Use symbols for consistency in your code. ## Accessing Data Context provides multiple access patterns with automatic nil safety: ```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] > Accessing undefined context attributes returns `nil` instead of raising errors, enabling graceful handling of optional attributes. ## 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 Context enables seamless data flow between related tasks in complex workflows: ```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) ``` --- url: https://github.com/drexed/cmdx/blob/main/docs/basics/chain.md --- # Basics - Chain Chains automatically group related task executions within a thread, providing unified tracking, correlation, and execution context management. Each thread maintains its own chain through thread-local storage, eliminating the need for manual coordination. ## Management Each thread maintains its own chain context through thread-local storage, providing automatic isolation without manual coordination. > [!WARNING] > Chain operations are thread-local. Never share chain references across threads as this can lead to race conditions and data corruption. ```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 Every task execution automatically creates or joins the current thread's chain: > [!IMPORTANT] > Chain creation is automatic and transparent. You don't need to manually manage chain lifecycle. ```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 When tasks call subtasks within the same thread, all executions automatically inherit the current chain, creating 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 provide comprehensive execution information with state delegation: > [!IMPORTANT] > Chain state always reflects the first (outer-most) task result, not individual subtask outcomes. Subtasks maintain their own success/failure 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 ``` --- url: https://github.com/drexed/cmdx/blob/main/docs/interruptions/halt.md --- # Interruptions - Halt Halting stops task execution with explicit intent signaling. Tasks provide two primary halt methods that control execution flow and result in different outcomes. ## Skipping `skip!` communicates that the task is to be intentionally bypassed. This represents a controlled, intentional interruption where the task determines that execution is not necessary or appropriate. > [!IMPORTANT] > Skipping is a no-op, not a failure or error and are considered successful outcomes. ```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 `fail!` communicates that the task encountered an impediment that prevents successful completion. This represents controlled failure where the task explicitly determines that execution cannot continue. ```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 Both halt methods accept metadata to provide additional context about the interruption. Metadata is stored as a hash and becomes available through the result object. ```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: