Skip to content

Configuration

Configure CMDx to register components, control logging, and customize framework behavior. Configuration lives at two levels: global defaults and per-class overrides.

Configuration Hierarchy

CMDx uses a two-tier configuration system:

  1. Global Configuration — Framework-wide defaults via CMDx.configure
  2. Class-level overrides — On the task class via settings, register, deregister, retry_on, deprecation

Important

Class-level registries are lazily duplicated from the parent on first access. Configure globals before any task touches a registry, or call CMDx.reset_configuration! in test setup to invalidate cached copies on Task.

Global Configuration

Default Values

Setting Default Description
logger Logger.new($stdout, progname: "cmdx", formatter: Line.new, level: INFO) Logger instance
log_level nil Optional override applied on top of logger.level (nil = use the logger's own level)
log_formatter nil Optional override applied on top of logger.formatter (nil = use the logger's own formatter)
log_exclusions [] Result#to_h keys stripped from the lifecycle log entry (e.g. [:context])
default_locale "en" Locale for built-in translation fallbacks
backtrace_cleaner nil Callable to clean fault backtraces
strict_context false Raise NoMethodError on unknown context.foo reads
correlation_id nil Callable resolved once per root execution; surfaced as xid on Chain/Result/Telemetry::Event
middlewares Middlewares.new (empty) Middleware registry
callbacks Callbacks.new (empty) Callback registry
coercions Coercions.new (13 built-ins) Coercion registry
validators Validators.new (7 built-ins) Validator registry
executors Executors.new (:threads, :fibers) Parallel-group executor registry
mergers Mergers.new (:last_write_wins, :deep_merge, :no_merge) Parallel-group merge-strategy registry
retriers Retriers.new (7 built-ins) Retry/jitter strategy registry
deprecators Deprecators.new (:log, :warn, :error) Deprecation handler registry
telemetry Telemetry.new (empty) Telemetry pub/sub

Default Locale

Set the locale used for built-in translation fallbacks when the I18n gem isn't loaded. See Internationalization for the full locale list.

CMDx.configure do |config|
  config.default_locale = "es"
end

Note

When I18n is loaded, CMDx delegates to I18n.translate and default_locale is unused — locale comes from I18n.locale. Without I18n, all built-in messages (validation errors, coercion errors, etc.) resolve from this setting.

Backtrace Cleaner

Trim noise from Fault backtraces with any callable that takes Array<String> and returns a cleaned array.

CMDx.configure do |config|
  config.backtrace_cleaner = ->(bt) { bt.reject { |l| l.include?("/gems/") } }

  # Rails:
  config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) }
end

Note

Rails apps wire this automatically via CMDx::Railtie.

Strict Context

Raise NoMethodError for unknown dynamic context reads instead of returning nil. Applies to the ctx.foo reader only — [], fetch, dig, key?, and predicate ctx.foo? keep their lenient behavior. See Context - Strict Mode for usage.

CMDx.configure do |config|
  config.strict_context = true
end

Override per-task via settings(strict_context: true).

Correlation ID (xid)

Thread an external correlation id (e.g. Rails request_id) through every task in a chain so they can be filtered together in logs and telemetry. Provide a callable; Runtime invokes it once per root execution when it builds the chain. The resolved value is stored on the Chain and surfaced as xid on every Result#to_h and Telemetry::Event.

CMDx.configure do |config|
  config.correlation_id = -> { Current.request_id }
end

result = ProcessOrder.execute(order_id: 42)
result.xid                            #=> "abc-123-..."
result.chain.map(&:xid).uniq          #=> ["abc-123-..."] (every task in the chain shares it)

Override per-task via settings(correlation_id: ->{ ... }) to give a specific task family its own resolver. Returning nil from the callable leaves xid as nil. Resolver exceptions propagate (so misconfigured resolvers fail loudly).

Note

The resolver fires only on the root chain creation. Nested subtasks inherit the same xid via the shared chain — even if the underlying thread-local mutates mid-flight, every result in the same execution sees a stable value.

Logging

CMDx.configure do |config|
  config.logger         = Logger.new($stdout, progname: "cmdx")
  config.log_level      = Logger::DEBUG
  config.log_formatter  = CMDx::LogFormatters::JSON.new
  config.log_exclusions = [:context]
end

Built-in formatters live under CMDx::LogFormatters: Line (default), JSON, KeyValue, Logstash, Raw. See Logging for the emitted fields and sample output.

log_exclusions trims noisy keys from the logged Result#to_h (common targets: :context, :metadata, :tags). The returned Result and telemetry payloads still carry the full data — only the log line is filtered.

Middlewares

Middlewares wrap the entire task lifecycle. The signature is call(task) { ... } — call yield (or next_link.call from a Proc) to invoke the next link.

CMDx.configure do |config|
  # Class with #call(task)
  config.middlewares.register CustomMiddleware

  # Instance
  config.middlewares.register CustomMiddleware.new(threshold: 1000)

  # Proc / Lambda — must declare &next_link to pass the block
  config.middlewares.register(proc do |task, &next_link|
    locale = Current.user.locale || I18n.default_locale
    I18n.with_locale(locale) do
      task.metadata[:locale] = locale
      next_link.call
    end
  end)

  # Insert at a specific position
  config.middlewares.register MyOuterMiddleware, at: 0

  # Remove
  config.middlewares.deregister CustomMiddleware
end

Caution

A middleware that forgets to yield raises CMDx::MiddlewareError — the task body is never invoked, so silent skips are caught immediately.

See the Middlewares docs for class-level configuration.

Callbacks

Callbacks fire at specific lifecycle points. Valid events:

Event When
:before_execution Before any work is executed
:before_validation Right after :before_execution, before input resolution
:around_execution Wraps work and any rollback; must invoke its continuation
:after_execution After work and rollback is executed
:on_complete When state == "complete" (success path)
:on_interrupted When state == "interrupted" (skip or fail)
:on_success When status == "success"
:on_skipped When status == "skipped"
:on_failed When status == "failed"
:on_ok Success or skipped (signal.ok? — not failed)
:on_ko Skipped or failed (signal.ko? — not success)
CMDx.configure do |config|
  # Symbol — dispatched as task.send(:method)
  config.callbacks.register :before_execution, :initialize_session

  # Class / instance with #call(task)
  config.callbacks.register :on_success, LogUserActivity

  # Proc / Lambda — instance_exec'd on the task; receives task as block arg.
  # The Result isn't built yet during callbacks; subscribe to Telemetry's
  # :task_executed event when you need result data like duration.
  config.callbacks.register(:on_complete, proc do |task|
    StatsD.increment("task.completed", tags: ["task:#{task.class}"])
  end)

  # Remove every callback for an event
  config.callbacks.deregister :on_success

  # Or remove a specific entry — match by `==` (Procs/Lambdas by identity)
  config.callbacks.deregister :on_success, LogUserActivity
end

Note

deregister(event) drops every callback for that event; pass a second arg to remove only matching entries (==). Unknown events raise ArgumentError; unmatched callables are silent no-ops.

See Callbacks for class-level usage.

Telemetry

Pub/sub for runtime lifecycle events. Subscribers receive a Telemetry::Event data object with cid, xid, root, type, task, tid, name, payload, and timestamp.

Event Payload
:task_started empty
:task_deprecated empty
:task_retried { attempt: Integer }
:task_rolled_back empty
:task_executed { result: Result }
CMDx.configure do |config|
  config.telemetry.subscribe(:task_executed, ->(event) {
    StatsD.timing("cmdx.task", event.payload[:result].duration, tags: [
      "class:#{event.task}",
      "status:#{event.payload[:result].status}"
    ])
  })

  config.telemetry.subscribe(:task_retried, ->(event) {
    Rails.logger.warn("[cmdx] retry ##{event.payload[:attempt]} for #{event.task}")
  })

  config.telemetry.unsubscribe(:task_executed, my_subscriber)
end

Tip

Runtime emits events only when subscribers exist for them, so unused events have zero overhead.

Coercions

Custom coercions are callables receiving (value, **options) and returning the coerced value or CMDx::Coercions::Failure.new(message) on failure.

CMDx.configure do |config|
  config.coercions.register :currency, CurrencyCoercion

  config.coercions.register(:tag_list, proc do |value, **opts|
    delimiter = opts[:delimiter] || ","
    max_tags  = opts[:max_tags] || 50
    value.to_s.split(delimiter).map(&:strip).reject(&:empty?).first(max_tags)
  end)

  config.coercions.deregister :currency
end

See Inputs - Coercions for usage.

Validators

Custom validators are callables receiving (value, options) (options is a positional hash). Return CMDx::Validators::Failure.new(message) to mark the value invalid; any other return value (including nil) is treated as success.

CMDx.configure do |config|
  config.validators.register :uuid, UuidValidator

  config.validators.register(:access_token, proc do |value, options|
    prefix = options[:prefix] || "tok_"
    min    = options[:min_length] || 40

    unless value.is_a?(String) && value.start_with?(prefix) && value.length >= min
      CMDx::Validators::Failure.new("invalid access token")
    end
  end)

  config.validators.deregister :uuid
end

See Inputs - Validations for usage.

Executors

Named concurrency backends used by Workflow :parallel groups. An executor is any callable with signature call(jobs:, concurrency:, on_job:) that invokes on_job.call(job) for each job and blocks until all jobs complete. Built-ins: :threads (default), :fibers.

CMDx.configure do |config|
  # Class or instance with #call(jobs:, concurrency:, on_job:)
  config.executors.register :ractor, RactorExecutor

  # Proc / Lambda
  config.executors.register(:inline, proc do |jobs:, concurrency:, on_job:|
    jobs.each { |job| on_job.call(job) }
  end)

  config.executors.deregister :fibers
end

See Workflows - Parallel Groups for usage.

Mergers

Named strategies for folding successful parallel task results back into the workflow context. A merger is any callable with signature call(workflow_context, result). Built-ins: :last_write_wins (default), :deep_merge, :no_merge.

CMDx.configure do |config|
  # Only merge specific keys
  config.mergers.register(:whitelist, proc do |ctx, result|
    result.context.to_h.slice(:user_id, :tenant_id).each { |k, v| ctx[k] = v }
  end)

  config.mergers.deregister :no_merge
end

See Workflows - Parallel Groups for usage.

Class-Level Configuration

Settings

Settings exposes a small set of per-class overrides for logger and tagging:

class GenerateInvoice < CMDx::Task
  settings(
    logger: CustomLogger.new($stdout),
    log_formatter: CMDx::LogFormatters::JSON.new,
    log_level: Logger::DEBUG,
    log_exclusions: [:context, :metadata],
    backtrace_cleaner: ->(bt) { bt.first(8) },
    tags: ["billing", "financial"],
    strict_context: true
  )

  def work
    # ...
  end
end

Every getter falls back to the global configuration when an option isn't set. Subclasses inherit and may layer on top — multiple settings(...) calls compose (each merges on top of the previous).

class BaseTask < CMDx::Task
  settings(tags: ["api"])
end

class ChildTask < BaseTask
  settings(tags: ["billing"], log_level: Logger::DEBUG)
  # tags = ["billing"] (child wins; settings.build does Hash#merge)
end

Note

Settings only stores logging/tracing options (:logger, :log_formatter, :log_level, :log_exclusions, :backtrace_cleaner, :tags, :strict_context, :correlation_id). Other config uses dedicated DSL (retry_on, deprecation, register, before_execution, …).

Retry

Configure exception-based retries with retry_on. Accumulates across inheritance.

class FetchInvoice < CMDx::Task
  retry_on Net::OpenTimeout, Net::ReadTimeout,
    limit: 3,
    delay: 0.5,
    max_delay: 5.0,
    jitter: :exponential   # :exponential, :half_random, :full_random, :bounded_random, :linear, :fibonacci, :decorrelated_jitter

  retry_on External::ApiError, limit: 5 do |attempt, delay|
    delay * (attempt + 1)  # custom jitter block
  end
end

Note

jitter: takes precedence over a custom block — pass one or the other, not both, or the block is silently ignored.

Deprecation

See Deprecation. Declared via the class-level deprecation DSL — not via settings.

class LegacyTask < CMDx::Task
  deprecation :error, if: -> { Rails.env.production? }
end

Registrations

Register or deregister middlewares, callbacks, coercions, and validators on a specific task class.

class SendCampaignEmail < CMDx::Task
  # Middlewares
  register :middleware, AuditTrailMiddleware
  deregister :middleware, GlobalLoggingMiddleware

  # Callbacks (use the dedicated DSL OR register :callback explicitly)
  before_execution :find_campaign
  on_complete proc { |task| Analytics.track("email_sent", task.context.recipient) }
  register :callback, :on_failed, :send_alert

  # Coercions
  register :coercion, :currency, CurrencyCoercion

  # Validators
  register :validator, :uuid, UuidValidator

  # Inputs / outputs (per-class schemas)
  register :input, :recipient_id, coerce: :integer, presence: true
  register :output, :delivered_at
end

See Inputs - Definitions and Outputs for the full schema DSL — the dedicated required / optional / output helpers are usually preferred over register :input / register :output.

Note

deregister mirrors register's arity. Callbacks: deregister :callback, event[, callable] (event clear, or matched by ==). Middlewares: deregister :middleware, callable_or_class (or at: index).

Configuration Management

Access

# Global
CMDx.configuration.logger              #=> <Logger instance>
CMDx.configuration.middlewares.size    #=> 0
CMDx.configuration.coercions.registry  #=> { array: ..., big_decimal: ..., ... }

# Class-level
class ProcessUpload < CMDx::Task
  settings(tags: ["files"])

  def work
    self.class.settings.tags        #=> ["files"]
    self.class.settings.logger      #=> falls back to CMDx.configuration.logger
    self.class.middlewares.size     #=> inherited count
  end
end

Resetting

CMDx.reset_configuration! replaces the global config with a fresh instance and invalidates the cached registries on Task so subclasses rebuild from the new config on next access.

CMDx.reset_configuration!

# Test setup (RSpec)
RSpec.configure do |config|
  config.before(:each) do
    CMDx.reset_configuration!
  end
end

Important

reset_configuration! clears registry caches on Task only — subclasses that already cached their own copy keep them. In tests, prefer freshly defined task classes per example (e.g. stub_const or anonymous classes).