cmdx 1.10.0 β†’ 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/LLM.md DELETED
@@ -1,3727 +0,0 @@
1
- # Getting Started
2
-
3
- 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.
4
-
5
- **Common challenges it solves:**
6
-
7
- - Inconsistent service object patterns across your codebase
8
- - Limited logging makes debugging a nightmare
9
- - Fragile error handling erodes confidence
10
-
11
- **What you get:**
12
-
13
- - Consistent, standardized architecture
14
- - Built-in flow control and error handling
15
- - Composable, reusable workflows
16
- - Comprehensive logging for observability
17
- - Attribute validation with type coercions
18
- - Sensible defaults and developer-friendly APIs
19
-
20
- ## The CERO Pattern
21
-
22
- CMDx embraces the Compose, Execute, React, Observe (CERO) patternβ€”a simple yet powerful approach to building reliable business logic.
23
-
24
- 🧩 **Compose** β€” Define small, focused tasks with typed attributes and validations
25
-
26
- ⚑ **Execute** β€” Run tasks with clear outcomes and pluggable behaviors
27
-
28
- πŸ”„ **React** β€” Adapt to outcomes by chaining follow-up tasks or handling faults
29
-
30
- πŸ” **Observe** β€” Capture structured logs and execution chains for debugging
31
-
32
- ## Installation
33
-
34
- Add CMDx to your Gemfile:
35
-
36
- ```ruby
37
- gem 'cmdx'
38
- ```
39
-
40
- For Rails applications, generate the configuration:
41
-
42
- ```bash
43
- rails generate cmdx:install
44
- ```
45
-
46
- This creates `config/initializers/cmdx.rb` file.
47
-
48
- ## Configuration Hierarchy
49
-
50
- CMDx uses a straightforward two-tier configuration system:
51
-
52
- 1. **Global Configuration** β€” Framework-wide defaults
53
- 2. **Task Settings** β€” Class-level overrides using `settings`
54
-
55
- !!! warning "Important"
56
-
57
- Task settings take precedence over global config. Settings are inherited from parent classes and can be overridden in subclasses.
58
-
59
- ## Global Configuration
60
-
61
- Configure framework-wide defaults that apply to all tasks. These settings come with sensible defaults out of the box.
62
-
63
- ### Breakpoints
64
-
65
- Control when `execute!` raises a `CMDx::Fault` based on task status.
66
-
67
- ```ruby
68
- CMDx.configure do |config|
69
- config.task_breakpoints = "failed" # String or Array[String]
70
- end
71
- ```
72
-
73
- For workflows, configure which statuses halt the execution pipeline:
74
-
75
- ```ruby
76
- CMDx.configure do |config|
77
- config.workflow_breakpoints = ["skipped", "failed"]
78
- end
79
- ```
80
-
81
- ### Rollback
82
-
83
- Control when a `rollback` of task execution is called.
84
-
85
- ```ruby
86
- CMDx.configure do |config|
87
- config.rollback_on = ["failed"] # String or Array[String]
88
- end
89
- ```
90
-
91
- ### Backtraces
92
-
93
- Enable detailed backtraces for non-fault exceptions to improve debugging. Optionally clean up stack traces to remove framework noise.
94
-
95
- !!! note
96
-
97
- In Rails environments, `backtrace_cleaner` defaults to `Rails.backtrace_cleaner.clean`.
98
-
99
- ```ruby
100
- CMDx.configure do |config|
101
- # Truthy
102
- config.backtrace = true
103
-
104
- # Via callable (must respond to `call(backtrace)`)
105
- config.backtrace_cleaner = AdvanceCleaner.new
106
-
107
- # Via proc or lambda
108
- config.backtrace_cleaner = ->(backtrace) { backtrace[0..5] }
109
- end
110
- ```
111
-
112
- ### Exception Handlers
113
-
114
- Register handlers that run when non-fault exceptions occur.
115
-
116
- !!! tip
117
-
118
- Use exception handlers to send errors to your APM of choice.
119
-
120
- ```ruby
121
- CMDx.configure do |config|
122
- # Via callable (must respond to `call(task, exception)`)
123
- config.exception_handler = NewRelicReporter
124
-
125
- # Via proc or lambda
126
- config.exception_handler = proc do |task, exception|
127
- APMService.report(exception, extra_data: { task: task.name, id: task.id })
128
- end
129
- end
130
- ```
131
-
132
- ### Logging
133
-
134
- ```ruby
135
- CMDx.configure do |config|
136
- config.logger = CustomLogger.new($stdout)
137
- end
138
- ```
139
-
140
- ### Middlewares
141
-
142
- See the [Middlewares](https://github.com/drexed/cmdx/blob/main/docs/middlewares.md#declarations) docs for task level configurations.
143
-
144
- ```ruby
145
- CMDx.configure do |config|
146
- # Via callable (must respond to `call(task, options)`)
147
- config.middlewares.register CMDx::Middlewares::Timeout
148
-
149
- # Via proc or lambda
150
- config.middlewares.register proc { |task, options|
151
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
- result = yield
153
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
154
- Rails.logger.debug { "task completed in #{((end_time - start_time) * 1000).round(2)}ms" }
155
- result
156
- }
157
-
158
- # With options
159
- config.middlewares.register AuditTrailMiddleware, service_name: "document_processor"
160
-
161
- # Remove middleware
162
- config.middlewares.deregister CMDx::Middlewares::Timeout
163
- end
164
- ```
165
-
166
- !!! note
167
-
168
- Middlewares are executed in registration order. Each middleware wraps the next, creating an execution chain around task logic.
169
-
170
- ### Callbacks
171
-
172
- See the [Callbacks](https://github.com/drexed/cmdx/blob/main/docs/callbacks.md#declarations) docs for task level configurations.
173
-
174
- ```ruby
175
- CMDx.configure do |config|
176
- # Via method
177
- config.callbacks.register :before_execution, :initialize_user_session
178
-
179
- # Via callable (must respond to `call(task)`)
180
- config.callbacks.register :on_success, LogUserActivity
181
-
182
- # Via proc or lambda
183
- config.callbacks.register :on_complete, proc { |task|
184
- execution_time = task.metadata[:runtime]
185
- Metrics.timer("task.execution_time", execution_time, tags: ["task:#{task.class.name.underscore}"])
186
- }
187
-
188
- # With options
189
- config.callbacks.register :on_failure, :send_alert_notification, if: :critical_task?
190
-
191
- # Remove callback
192
- config.callbacks.deregister :on_success, LogUserActivity
193
- end
194
- ```
195
-
196
- ### Coercions
197
-
198
- See the [Attributes - Coercions](https://github.com/drexed/cmdx/blob/main/docs/attributes/coercions.md#declarations) docs for task level configurations.
199
-
200
- ```ruby
201
- CMDx.configure do |config|
202
- # Via callable (must respond to `call(value, options)`)
203
- config.coercions.register :currency, CurrencyCoercion
204
-
205
- # Via method (must match signature `def coordinates_coercion(value, options)`)
206
- config.coercions.register :coordinates, :coordinates_coercion
207
-
208
- # Via proc or lambda
209
- config.coercions.register :tag_list, proc { |value, options|
210
- delimiter = options[:delimiter] || ','
211
- max_tags = options[:max_tags] || 50
212
-
213
- tags = value.to_s.split(delimiter).map(&:strip).reject(&:empty?)
214
- tags.first(max_tags)
215
- }
216
-
217
- # Remove coercion
218
- config.coercions.deregister :currency
219
- end
220
- ```
221
-
222
- ### Validators
223
-
224
- See the [Attributes - Validations](https://github.com/drexed/cmdx/blob/main/docs/attributes/validations.md#declarations) docs for task level configurations.
225
-
226
- ```ruby
227
- CMDx.configure do |config|
228
- # Via callable (must respond to `call(value, options)`)
229
- config.validators.register :username, UsernameValidator
230
-
231
- # Via method (must match signature `def url_validator(value, options)`)
232
- config.validators.register :url, :url_validator
233
-
234
- # Via proc or lambda
235
- config.validators.register :access_token, proc { |value, options|
236
- expected_prefix = options[:prefix] || "tok_"
237
- minimum_length = options[:min_length] || 40
238
-
239
- value.start_with?(expected_prefix) && value.length >= minimum_length
240
- }
241
-
242
- # Remove validator
243
- config.validators.deregister :username
244
- end
245
- ```
246
-
247
- ## Task Configuration
248
-
249
- ### Settings
250
-
251
- Override global configuration for specific tasks using `settings`:
252
-
253
- ```ruby
254
- class GenerateInvoice < CMDx::Task
255
- settings(
256
- # Global configuration overrides
257
- task_breakpoints: ["failed"], # Breakpoint override
258
- workflow_breakpoints: [], # Breakpoint override
259
- backtrace: true, # Toggle backtrace
260
- backtrace_cleaner: ->(bt) { bt[0..5] }, # Backtrace cleaner
261
- logger: CustomLogger.new($stdout), # Custom logger
262
-
263
- # Task configuration settings
264
- breakpoints: ["failed"], # Contextual pointer for :task_breakpoints and :workflow_breakpoints
265
- log_level: :info, # Log level override
266
- log_formatter: CMDx::LogFormatters::Json.new # Log formatter override
267
- tags: ["billing", "financial"], # Logging tags
268
- deprecated: true, # Task deprecations
269
- retries: 3, # Non-fault exception retries
270
- retry_on: [External::ApiError], # List of exceptions to retry on
271
- retry_jitter: 1, # Space between retry iteration, eg: current retry num + 1
272
- rollback_on: ["failed", "skipped"], # Rollback on override
273
- )
274
-
275
- def work
276
- # Your logic here...
277
- end
278
- end
279
- ```
280
-
281
- !!! warning "Important"
282
-
283
- Retries reuse the same context. By default, all `StandardError` exceptions (including faults) are retried unless you specify `retry_on` option for specific matches.
284
-
285
- ### Registrations
286
-
287
- Register or deregister middlewares, callbacks, coercions, and validators for specific tasks:
288
-
289
- ```ruby
290
- class SendCampaignEmail < CMDx::Task
291
- # Middlewares
292
- register :middleware, CMDx::Middlewares::Timeout
293
- deregister :middleware, AuditTrailMiddleware
294
-
295
- # Callbacks
296
- register :callback, :on_complete, proc { |task|
297
- runtime = task.metadata[:runtime]
298
- Analytics.track("email_campaign.sent", runtime, tags: ["task:#{task.class.name}"])
299
- }
300
- deregister :callback, :before_execution, :initialize_user_session
301
-
302
- # Coercions
303
- register :coercion, :currency, CurrencyCoercion
304
- deregister :coercion, :coordinates
305
-
306
- # Validators
307
- register :validator, :username, :username_validator
308
- deregister :validator, :url
309
-
310
- def work
311
- # Your logic here...
312
- end
313
- end
314
- ```
315
-
316
- ## Configuration Management
317
-
318
- ### Access
319
-
320
- ```ruby
321
- # Global configuration access
322
- CMDx.configuration.logger #=> <Logger instance>
323
- CMDx.configuration.task_breakpoints #=> ["failed"]
324
- CMDx.configuration.middlewares.registry #=> [<Middleware>, ...]
325
-
326
- # Task configuration access
327
- class ProcessUpload < CMDx::Task
328
- settings(tags: ["files", "storage"])
329
-
330
- def work
331
- self.class.settings[:logger] #=> Global configuration value
332
- self.class.settings[:tags] #=> Task configuration value => ["files", "storage"]
333
- end
334
- end
335
- ```
336
-
337
- ### Resetting
338
-
339
- !!! warning
340
-
341
- Resetting affects your entire application. Use this primarily in test environments.
342
-
343
- ```ruby
344
- # Reset to framework defaults
345
- CMDx.reset_configuration!
346
-
347
- # Verify reset
348
- CMDx.configuration.task_breakpoints #=> ["failed"] (default)
349
- CMDx.configuration.middlewares.registry #=> Empty registry
350
-
351
- # Commonly used in test setup (RSpec example)
352
- RSpec.configure do |config|
353
- config.before(:each) do
354
- CMDx.reset_configuration!
355
- end
356
- end
357
- ```
358
-
359
- ## Task Generator
360
-
361
- Generate new CMDx tasks quickly using the built-in generator:
362
-
363
- ```bash
364
- rails generate cmdx:task ModerateBlogPost
365
- ```
366
-
367
- This creates a new task file with the basic structure:
368
-
369
- ```ruby
370
- # app/tasks/moderate_blog_post.rb
371
- class ModerateBlogPost < CMDx::Task
372
- def work
373
- # Your logic here...
374
- end
375
- end
376
- ```
377
-
378
- !!! tip
379
-
380
- Use **present tense verbs + noun** for task names, eg: `ModerateBlogPost`, `ScheduleAppointment`, `ValidateDocument`
381
-
382
- ## Type safety
383
-
384
- CMDx includes built-in RBS (Ruby Type Signature) inline annotations throughout the codebase, providing type information for static analysis and editor support.
385
-
386
- - **Type checking** β€” Catch type errors before runtime using tools like Steep or TypeProf
387
- - **Better IDE support** β€” Enhanced autocomplete, navigation, and inline documentation
388
- - **Self-documenting code** β€” Clear method signatures and return types
389
- - **Refactoring confidence** β€” Type-aware refactoring reduces bugs
390
-
391
- # Basics - Setup
392
-
393
- Tasks are the heart of CMDxβ€”self-contained units of business logic with built-in validation, error handling, and execution tracking.
394
-
395
- ## Structure
396
-
397
- Tasks need only two things: inherit from `CMDx::Task` and define a `work` method:
398
-
399
- ```ruby
400
- class ValidateDocument < CMDx::Task
401
- def work
402
- # Your logic here...
403
- end
404
- end
405
- ```
406
-
407
- Without a `work` method, execution raises `CMDx::UndefinedMethodError`.
408
-
409
- ```ruby
410
- class IncompleteTask < CMDx::Task
411
- # No `work` method defined
412
- end
413
-
414
- IncompleteTask.execute #=> raises CMDx::UndefinedMethodError
415
- ```
416
-
417
- ## Rollback
418
-
419
- Undo any operations linked to the given status, helping to restore a pristine state.
420
-
421
- ```ruby
422
- class ValidateDocument < CMDx::Task
423
- def work
424
- # Your logic here...
425
- end
426
-
427
- def rollback
428
- # Your undo logic...
429
- end
430
- end
431
- ```
432
-
433
- ## Inheritance
434
-
435
- Share configuration across tasks using inheritance:
436
-
437
- ```ruby
438
- class ApplicationTask < CMDx::Task
439
- register :middleware, SecurityMiddleware
440
-
441
- before_execution :initialize_request_tracking
442
-
443
- attribute :session_id
444
-
445
- private
446
-
447
- def initialize_request_tracking
448
- context.tracking_id ||= SecureRandom.uuid
449
- end
450
- end
451
-
452
- class SyncInventory < ApplicationTask
453
- def work
454
- # Your logic here...
455
- end
456
- end
457
- ```
458
-
459
- ## Lifecycle
460
-
461
- Tasks follow a predictable execution pattern:
462
-
463
- !!! danger "Caution"
464
-
465
- Tasks are single-use objects. Once executed, they're frozen and immutable.
466
-
467
- | Stage | State | Status | Description |
468
- |-------|-------|--------|-------------|
469
- | **Instantiation** | `initialized` | `success` | Task created with context |
470
- | **Validation** | `executing` | `success`/`failed` | Attributes validated |
471
- | **Execution** | `executing` | `success`/`failed`/`skipped` | `work` method runs |
472
- | **Completion** | `executed` | `success`/`failed`/`skipped` | Result finalized |
473
- | **Freezing** | `executed` | `success`/`failed`/`skipped` | Task becomes immutable |
474
- | **Rollback** | `executed` | `failed`/`skipped` | Work undone |
475
-
476
- # Basics - Execution
477
-
478
- CMDx offers two execution methods with different error handling approaches. Choose based on your needs: safe result handling or exception-based control flow.
479
-
480
- ## Execution Methods
481
-
482
- Both methods return results, but handle failures differently:
483
-
484
- | Method | Returns | Exceptions | Use Case |
485
- |--------|---------|------------|----------|
486
- | `execute` | Always returns `CMDx::Result` | Never raises | Predictable result handling |
487
- | `execute!` | Returns `CMDx::Result` on success | Raises `CMDx::Fault` when skipped or failed | Exception-based control flow |
488
-
489
- ## Non-bang Execution
490
-
491
- Always returns a `CMDx::Result`, never raises exceptions. Perfect for most use cases.
492
-
493
- ```ruby
494
- result = CreateAccount.execute(email: "user@example.com")
495
-
496
- # Check execution state
497
- result.success? #=> true/false
498
- result.failed? #=> true/false
499
- result.skipped? #=> true/false
500
-
501
- # Access result data
502
- result.context.email #=> "user@example.com"
503
- result.state #=> "complete"
504
- result.status #=> "success"
505
- ```
506
-
507
- ## Bang Execution
508
-
509
- Raises `CMDx::Fault` exceptions on failure or skip. Returns results only on success.
510
-
511
- | Exception | Raised When |
512
- |-----------|-------------|
513
- | `CMDx::FailFault` | Task execution fails |
514
- | `CMDx::SkipFault` | Task execution is skipped |
515
-
516
- !!! warning "Important"
517
-
518
- Behavior depends on `task_breakpoints` or `workflow_breakpoints` config. Default: only failures raise exceptions.
519
-
520
- ```ruby
521
- begin
522
- result = CreateAccount.execute!(email: "user@example.com")
523
- SendWelcomeEmail.execute(result.context)
524
- rescue CMDx::FailFault => e
525
- ScheduleAccountRetryJob.perform_later(e.result.context.email)
526
- rescue CMDx::SkipFault => e
527
- Rails.logger.info("Account creation skipped: #{e.result.reason}")
528
- rescue Exception => e
529
- ErrorTracker.capture(unhandled_exception: e)
530
- end
531
- ```
532
-
533
- ## Direct Instantiation
534
-
535
- Tasks can be instantiated directly for advanced use cases, testing, and custom execution patterns:
536
-
537
- ```ruby
538
- # Direct instantiation
539
- task = CreateAccount.new(email: "user@example.com", send_welcome: true)
540
-
541
- # Access properties before execution
542
- task.id #=> "abc123..." (unique task ID)
543
- task.context.email #=> "user@example.com"
544
- task.context.send_welcome #=> true
545
- task.result.state #=> "initialized"
546
- task.result.status #=> "success"
547
-
548
- # Manual execution
549
- task.execute
550
- # or
551
- task.execute!
552
-
553
- task.result.success? #=> true/false
554
- ```
555
-
556
- ## Result Details
557
-
558
- The `Result` object provides comprehensive execution information:
559
-
560
- ```ruby
561
- result = CreateAccount.execute(email: "user@example.com")
562
-
563
- # Execution metadata
564
- result.id #=> "abc123..." (unique execution ID)
565
- result.task #=> CreateAccount instance (frozen)
566
- result.chain #=> Task execution chain
567
-
568
- # Context and metadata
569
- result.context #=> Context with all task data
570
- result.metadata #=> Hash with execution metadata
571
- ```
572
-
573
- # Basics - Context
574
-
575
- Context is your data container for inputs, intermediate values, and outputs. It makes sharing data between tasks effortless.
576
-
577
- ## Assigning Data
578
-
579
- Context automatically captures all task inputs, normalizing keys to symbols:
580
-
581
- ```ruby
582
- # Direct execution
583
- CalculateShipping.execute(weight: 2.5, destination: "CA")
584
-
585
- # Instance creation
586
- CalculateShipping.new(weight: 2.5, "destination" => "CA")
587
- ```
588
-
589
- !!! warning "Important"
590
-
591
- String keys convert to symbols automatically. Prefer symbols for consistency.
592
-
593
- ## Accessing Data
594
-
595
- Access context data using method notation, hash keys, or safe accessors:
596
-
597
- ```ruby
598
- class CalculateShipping < CMDx::Task
599
- def work
600
- # Method style access (preferred)
601
- weight = context.weight
602
- destination = context.destination
603
-
604
- # Hash style access
605
- service_type = context[:service_type]
606
- options = context["options"]
607
-
608
- # Safe access with defaults
609
- rush_delivery = context.fetch!(:rush_delivery, false)
610
- carrier = context.dig(:options, :carrier)
611
-
612
- # Shorter alias
613
- cost = ctx.weight * ctx.rate_per_pound # ctx aliases context
614
- end
615
- end
616
- ```
617
-
618
- !!! warning "Important"
619
-
620
- Undefined attributes return `nil` instead of raising errorsβ€”perfect for optional data.
621
-
622
- ## Modifying Context
623
-
624
- Context supports dynamic modification during task execution:
625
-
626
- ```ruby
627
- class CalculateShipping < CMDx::Task
628
- def work
629
- # Direct assignment
630
- context.carrier = Carrier.find_by(code: context.carrier_code)
631
- context.package = Package.new(weight: context.weight)
632
- context.calculated_at = Time.now
633
-
634
- # Hash-style assignment
635
- context[:status] = "calculating"
636
- context["tracking_number"] = "SHIP#{SecureRandom.hex(6)}"
637
-
638
- # Conditional assignment
639
- context.insurance_included ||= false
640
-
641
- # Batch updates
642
- context.merge!(
643
- status: "completed",
644
- shipping_cost: calculate_cost,
645
- estimated_delivery: Time.now + 3.days
646
- )
647
-
648
- # Remove sensitive data
649
- context.delete!(:credit_card_token)
650
- end
651
-
652
- private
653
-
654
- def calculate_cost
655
- base_rate = context.weight * context.rate_per_pound
656
- base_rate + (base_rate * context.tax_percentage)
657
- end
658
- end
659
- ```
660
-
661
- !!! tip
662
-
663
- Use context for both input values and intermediate results. This creates natural data flow through your task execution pipeline.
664
-
665
- ## Data Sharing
666
-
667
- Share context across tasks for seamless data flow:
668
-
669
- ```ruby
670
- # During execution
671
- class CalculateShipping < CMDx::Task
672
- def work
673
- # Validate shipping data
674
- validation_result = ValidateAddress.execute(context)
675
-
676
- # Via context
677
- CalculateInsurance.execute(context)
678
-
679
- # Via result
680
- NotifyShippingCalculated.execute(validation_result)
681
-
682
- # Context now contains accumulated data from all tasks
683
- context.address_validated #=> true (from validation)
684
- context.insurance_calculated #=> true (from insurance)
685
- context.notification_sent #=> true (from notification)
686
- end
687
- end
688
-
689
- # After execution
690
- result = CalculateShipping.execute(destination: "New York, NY")
691
-
692
- CreateShippingLabel.execute(result)
693
- ```
694
-
695
- # Basics - Chain
696
-
697
- 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.
698
-
699
- ## Management
700
-
701
- Each thread maintains its own isolated chain using thread-local storage.
702
-
703
- !!! warning
704
-
705
- Chains are thread-local. Don't share chain references across threadsβ€”it causes race conditions.
706
-
707
- ```ruby
708
- # Thread A
709
- Thread.new do
710
- result = ImportDataset.execute(file_path: "/data/batch1.csv")
711
- result.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5"
712
- end
713
-
714
- # Thread B (completely separate chain)
715
- Thread.new do
716
- result = ImportDataset.execute(file_path: "/data/batch2.csv")
717
- result.chain.id #=> "z3a42b95-c821-7892-b156-dd7c921fe2a3"
718
- end
719
-
720
- # Access current thread's chain
721
- CMDx::Chain.current #=> Returns current chain or nil
722
- CMDx::Chain.clear #=> Clears current thread's chain
723
- ```
724
-
725
- ## Links
726
-
727
- Tasks automatically create or join the current thread's chain:
728
-
729
- !!! warning "Important"
730
-
731
- Chain management is automaticβ€”no manual lifecycle handling needed.
732
-
733
- ```ruby
734
- class ImportDataset < CMDx::Task
735
- def work
736
- # First task creates new chain
737
- result1 = ValidateHeaders.execute(file_path: context.file_path)
738
- result1.chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5"
739
- result1.chain.results.size #=> 1
740
-
741
- # Second task joins existing chain
742
- result2 = SendNotification.execute(to: "admin@company.com")
743
- result2.chain.id == result1.chain.id #=> true
744
- result2.chain.results.size #=> 2
745
-
746
- # Both results reference the same chain
747
- result1.chain.results == result2.chain.results #=> true
748
- end
749
- end
750
- ```
751
-
752
- ## Inheritance
753
-
754
- Subtasks automatically inherit the current thread's chain, building a unified execution trail:
755
-
756
- ```ruby
757
- class ImportDataset < CMDx::Task
758
- def work
759
- context.dataset = Dataset.find(context.dataset_id)
760
-
761
- # Subtasks automatically inherit current chain
762
- ValidateSchema.execute
763
- TransformData.execute!(context)
764
- SaveToDatabase.execute(dataset_id: context.dataset_id)
765
- end
766
- end
767
-
768
- result = ImportDataset.execute(dataset_id: 456)
769
- chain = result.chain
770
-
771
- # All tasks share the same chain
772
- chain.results.size #=> 4 (main task + 3 subtasks)
773
- chain.results.map { |r| r.task.class }
774
- #=> [ImportDataset, ValidateSchema, TransformData, SaveToDatabase]
775
- ```
776
-
777
- ## Structure
778
-
779
- Chains expose comprehensive execution information:
780
-
781
- !!! warning "Important"
782
-
783
- Chain state reflects the first (outermost) task result. Subtasks maintain their own states.
784
-
785
- ```ruby
786
- result = ImportDataset.execute(dataset_id: 456)
787
- chain = result.chain
788
-
789
- # Chain identification
790
- chain.id #=> "018c2b95-b764-7615-a924-cc5b910ed1e5"
791
- chain.results #=> Array of all results in execution order
792
-
793
- # State delegation (from first/outer-most result)
794
- chain.state #=> "complete"
795
- chain.status #=> "success"
796
- chain.outcome #=> "success"
797
-
798
- # Access individual results
799
- chain.results.each_with_index do |result, index|
800
- puts "#{index}: #{result.task.class} - #{result.status}"
801
- end
802
- ```
803
-
804
- # Interruptions - Halt
805
-
806
- Stop task execution intentionally using `skip!` or `fail!`. Both methods signal clear intent about why execution stopped.
807
-
808
- ## Skipping
809
-
810
- Use `skip!` when the task doesn't need to run. It's a no-op, not an error.
811
-
812
- !!! warning "Important"
813
-
814
- Skipped tasks are considered "good" outcomesβ€”they succeeded by doing nothing.
815
-
816
- ```ruby
817
- class ProcessInventory < CMDx::Task
818
- def work
819
- # Without a reason
820
- skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name)
821
-
822
- # With a reason
823
- skip!("Warehouse closed") unless Time.now.hour.between?(8, 18)
824
-
825
- inventory = Inventory.find(context.inventory_id)
826
-
827
- if inventory.already_counted?
828
- skip!("Inventory already counted today")
829
- else
830
- inventory.count!
831
- end
832
- end
833
- end
834
-
835
- result = ProcessInventory.execute(inventory_id: 456)
836
-
837
- # Executed
838
- result.status #=> "skipped"
839
-
840
- # Without a reason
841
- result.reason #=> "Unspecified"
842
-
843
- # With a reason
844
- result.reason #=> "Warehouse closed"
845
- ```
846
-
847
- ## Failing
848
-
849
- Use `fail!` when the task can't complete successfully. It signals controlled, intentional failure:
850
-
851
- ```ruby
852
- class ProcessRefund < CMDx::Task
853
- def work
854
- # Without a reason
855
- fail! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name)
856
-
857
- refund = Refund.find(context.refund_id)
858
-
859
- # With a reason
860
- if refund.expired?
861
- fail!("Refund period has expired")
862
- elsif !refund.amount.positive?
863
- fail!("Refund amount must be positive")
864
- else
865
- refund.process!
866
- end
867
- end
868
- end
869
-
870
- result = ProcessRefund.execute(refund_id: 789)
871
-
872
- # Executed
873
- result.status #=> "failed"
874
-
875
- # Without a reason
876
- result.reason #=> "Unspecified"
877
-
878
- # With a reason
879
- result.reason #=> "Refund period has expired"
880
- ```
881
-
882
- ## Metadata Enrichment
883
-
884
- Enrich halt calls with metadata for better debugging and error handling:
885
-
886
- ```ruby
887
- class ProcessRenewal < CMDx::Task
888
- def work
889
- license = License.find(context.license_id)
890
-
891
- if license.already_renewed?
892
- # Without metadata
893
- skip!("License already renewed")
894
- end
895
-
896
- unless license.renewal_eligible?
897
- # With metadata
898
- fail!(
899
- "License not eligible for renewal",
900
- error_code: "LICENSE.NOT_ELIGIBLE",
901
- retry_after: Time.current + 30.days
902
- )
903
- end
904
-
905
- process_renewal
906
- end
907
- end
908
-
909
- result = ProcessRenewal.execute(license_id: 567)
910
-
911
- # Without metadata
912
- result.metadata #=> {}
913
-
914
- # With metadata
915
- result.metadata #=> {
916
- # error_code: "LICENSE.NOT_ELIGIBLE",
917
- # retry_after: <Time 30 days from now>
918
- # }
919
- ```
920
-
921
- ## State Transitions
922
-
923
- Halt methods trigger specific state and status transitions:
924
-
925
- | Method | State | Status | Outcome |
926
- |--------|-------|--------|---------|
927
- | `skip!` | `interrupted` | `skipped` | `good? = true`, `bad? = true` |
928
- | `fail!` | `interrupted` | `failed` | `good? = false`, `bad? = true` |
929
-
930
- ```ruby
931
- result = ProcessRenewal.execute(license_id: 567)
932
-
933
- # State information
934
- result.state #=> "interrupted"
935
- result.status #=> "skipped" or "failed"
936
- result.interrupted? #=> true
937
- result.complete? #=> false
938
-
939
- # Outcome categorization
940
- result.good? #=> true for skipped, false for failed
941
- result.bad? #=> true for both skipped and failed
942
- ```
943
-
944
- ## Execution Behavior
945
-
946
- Halt methods behave differently depending on the call method used:
947
-
948
- ### Non-bang execution
949
-
950
- Returns result object without raising exceptions:
951
-
952
- ```ruby
953
- result = ProcessRefund.execute(refund_id: 789)
954
-
955
- case result.status
956
- when "success"
957
- puts "Refund processed: $#{result.context.refund.amount}"
958
- when "skipped"
959
- puts "Refund skipped: #{result.reason}"
960
- when "failed"
961
- puts "Refund failed: #{result.reason}"
962
- handle_refund_error(result.metadata[:error_code])
963
- end
964
- ```
965
-
966
- ### Bang execution
967
-
968
- Raises exceptions for halt conditions based on `task_breakpoints` configuration:
969
-
970
- ```ruby
971
- begin
972
- result = ProcessRefund.execute!(refund_id: 789)
973
- puts "Success: Refund processed"
974
- rescue CMDx::SkipFault => e
975
- puts "Skipped: #{e.message}"
976
- rescue CMDx::FailFault => e
977
- puts "Failed: #{e.message}"
978
- handle_refund_failure(e.result.metadata[:error_code])
979
- end
980
- ```
981
-
982
- ## Best Practices
983
-
984
- Always provide a reason for better debugging and clearer exception messages:
985
-
986
- ```ruby
987
- # Good: Clear, specific reason
988
- skip!("Document processing paused for compliance review")
989
- fail!("File format not supported by processor", code: "FORMAT_UNSUPPORTED")
990
-
991
- # Acceptable: Generic, non-specific reason
992
- skip!("Paused")
993
- fail!("Unsupported")
994
-
995
- # Bad: Default, cannot determine reason
996
- skip! #=> "Unspecified"
997
- fail! #=> "Unspecified"
998
- ```
999
-
1000
- ## Manual Errors
1001
-
1002
- For rare cases, manually add errors before halting:
1003
-
1004
- !!! warning "Important"
1005
-
1006
- Manual errors don't stop executionβ€”you still need to call `fail!` or `skip!`.
1007
-
1008
- ```ruby
1009
- class ProcessRenewal < CMDx::Task
1010
- def work
1011
- if document.nonrenewable?
1012
- errors.add(:document, "not renewable")
1013
- fail!("document could not be renewed")
1014
- else
1015
- document.renew!
1016
- end
1017
- end
1018
- end
1019
- ```
1020
-
1021
- # Interruptions - Faults
1022
-
1023
- Faults are exceptions raised by `execute!` when tasks halt. They carry rich context about execution state, enabling sophisticated error handling patterns.
1024
-
1025
- ## Fault Types
1026
-
1027
- | Type | Triggered By | Use Case |
1028
- |------|--------------|----------|
1029
- | `CMDx::Fault` | Base class | Catch-all for any interruption |
1030
- | `CMDx::SkipFault` | `skip!` method | Optional processing, early returns |
1031
- | `CMDx::FailFault` | `fail!` method | Validation errors, processing failures |
1032
-
1033
- !!! warning "Important"
1034
-
1035
- All faults inherit from `CMDx::Fault` and expose result, task, context, and chain data.
1036
-
1037
- ## Fault Handling
1038
-
1039
- ```ruby
1040
- begin
1041
- ProcessTicket.execute!(ticket_id: 456)
1042
- rescue CMDx::SkipFault => e
1043
- logger.info "Ticket processing skipped: #{e.message}"
1044
- schedule_retry(e.context.ticket_id)
1045
- rescue CMDx::FailFault => e
1046
- logger.error "Ticket processing failed: #{e.message}"
1047
- notify_admin(e.context.assigned_agent, e.result.metadata[:error_code])
1048
- rescue CMDx::Fault => e
1049
- logger.warn "Ticket processing interrupted: #{e.message}"
1050
- rollback_changes
1051
- end
1052
- ```
1053
-
1054
- ## Data Access
1055
-
1056
- Access rich execution data from fault exceptions:
1057
-
1058
- ```ruby
1059
- begin
1060
- LicenseActivation.execute!(license_key: key, machine_id: machine)
1061
- rescue CMDx::Fault => e
1062
- # Result information
1063
- e.result.state #=> "interrupted"
1064
- e.result.status #=> "failed" or "skipped"
1065
- e.result.reason #=> "License key already activated"
1066
-
1067
- # Task information
1068
- e.task.class #=> <LicenseActivation>
1069
- e.task.id #=> "abc123..."
1070
-
1071
- # Context data
1072
- e.context.license_key #=> "ABC-123-DEF"
1073
- e.context.machine_id #=> "[FILTERED]"
1074
-
1075
- # Chain information
1076
- e.chain.id #=> "def456..."
1077
- e.chain.size #=> 3
1078
- end
1079
- ```
1080
-
1081
- ## Advanced Matching
1082
-
1083
- ### Task-Specific Matching
1084
-
1085
- Handle faults only from specific tasks using `for?`:
1086
-
1087
- ```ruby
1088
- begin
1089
- DocumentWorkflow.execute!(document_data: data)
1090
- rescue CMDx::FailFault.for?(FormatValidator, ContentProcessor) => e
1091
- # Handle only document-related failures
1092
- retry_with_alternate_parser(e.context)
1093
- rescue CMDx::SkipFault.for?(VirusScanner, ContentFilter) => e
1094
- # Handle security-related skips
1095
- quarantine_for_review(e.context.document_id)
1096
- end
1097
- ```
1098
-
1099
- ### Custom Logic Matching
1100
-
1101
- ```ruby
1102
- begin
1103
- ReportGenerator.execute!(report: report_data)
1104
- rescue CMDx::Fault.matches? { |f| f.context.data_size > 10_000 } => e
1105
- escalate_large_dataset_failure(e)
1106
- rescue CMDx::FailFault.matches? { |f| f.result.metadata[:attempt_count] > 3 } => e
1107
- abandon_report_generation(e)
1108
- rescue CMDx::Fault.matches? { |f| f.result.metadata[:error_type] == "memory" } => e
1109
- increase_memory_and_retry(e)
1110
- end
1111
- ```
1112
-
1113
- ## Fault Propagation
1114
-
1115
- Propagate failures with `throw!` to preserve context and maintain the error chain:
1116
-
1117
- ### Basic Propagation
1118
-
1119
- ```ruby
1120
- class ReportGenerator < CMDx::Task
1121
- def work
1122
- # Throw if skipped or failed
1123
- validation_result = DataValidator.execute(context)
1124
- throw!(validation_result)
1125
-
1126
- # Only throw if skipped
1127
- check_permissions = CheckPermissions.execute(context)
1128
- throw!(check_permissions) if check_permissions.skipped?
1129
-
1130
- # Only throw if failed
1131
- data_result = DataProcessor.execute(context)
1132
- throw!(data_result) if data_result.failed?
1133
-
1134
- # Continue processing
1135
- generate_report
1136
- end
1137
- end
1138
- ```
1139
-
1140
- ### Additional Metadata
1141
-
1142
- ```ruby
1143
- class BatchProcessor < CMDx::Task
1144
- def work
1145
- step_result = FileValidation.execute(context)
1146
-
1147
- if step_result.failed?
1148
- throw!(step_result, {
1149
- batch_stage: "validation",
1150
- can_retry: true,
1151
- next_step: "file_repair"
1152
- })
1153
- end
1154
-
1155
- continue_batch
1156
- end
1157
- end
1158
- ```
1159
-
1160
- ## Chain Analysis
1161
-
1162
- Trace fault origins and propagation through the execution chain:
1163
-
1164
- ```ruby
1165
- result = DocumentWorkflow.execute(invalid_data)
1166
-
1167
- if result.failed?
1168
- # Trace the original failure
1169
- original = result.caused_failure
1170
- if original
1171
- puts "Original failure: #{original.task.class.name}"
1172
- puts "Reason: #{original.reason}"
1173
- end
1174
-
1175
- # Find what propagated the failure
1176
- thrower = result.threw_failure
1177
- puts "Propagated by: #{thrower.task.class.name}" if thrower
1178
-
1179
- # Analyze failure type
1180
- case
1181
- when result.caused_failure?
1182
- puts "This task was the original source"
1183
- when result.threw_failure?
1184
- puts "This task propagated a failure"
1185
- when result.thrown_failure?
1186
- puts "This task failed due to propagation"
1187
- end
1188
- end
1189
- ```
1190
-
1191
- # Interruptions - Exceptions
1192
-
1193
- Exception handling differs between `execute` and `execute!`. Choose the method that matches your error handling strategy.
1194
-
1195
- ## Exception Handling
1196
-
1197
- !!! warning "Important"
1198
-
1199
- Prefer `skip!` and `fail!` over raising exceptionsβ€”they signal intent more clearly.
1200
-
1201
- ### Non-bang execution
1202
-
1203
- Captures all exceptions and returns them as failed results:
1204
-
1205
- ```ruby
1206
- class CompressDocument < CMDx::Task
1207
- def work
1208
- document = Document.find(context.document_id)
1209
- document.compress!
1210
- end
1211
- end
1212
-
1213
- result = CompressDocument.execute(document_id: "unknown-doc-id")
1214
- result.state #=> "interrupted"
1215
- result.status #=> "failed"
1216
- result.failed? #=> true
1217
- result.reason #=> "[ActiveRecord::NotFoundError] record not found"
1218
- result.cause #=> <ActiveRecord::NotFoundError>
1219
- ```
1220
-
1221
- !!! note
1222
-
1223
- Use `exception_handler` with `execute` to send exceptions to APM tools before they become failed results.
1224
-
1225
- ### Bang execution
1226
-
1227
- Lets exceptions propagate naturally for standard Ruby error handling:
1228
-
1229
- ```ruby
1230
- class CompressDocument < CMDx::Task
1231
- def work
1232
- document = Document.find(context.document_id)
1233
- document.compress!
1234
- end
1235
- end
1236
-
1237
- begin
1238
- CompressDocument.execute!(document_id: "unknown-doc-id")
1239
- rescue ActiveRecord::NotFoundError => e
1240
- puts "Handle exception: #{e.message}"
1241
- end
1242
- ```
1243
-
1244
- # Outcomes - Result
1245
-
1246
- Results are your window into task execution. They expose everything: outcome, state, timing, context, and metadata.
1247
-
1248
- ## Result Attributes
1249
-
1250
- Access essential execution information:
1251
-
1252
- !!! warning "Important"
1253
-
1254
- Results are immutable after execution completes.
1255
-
1256
- ```ruby
1257
- result = BuildApplication.execute(version: "1.2.3")
1258
-
1259
- # Object data
1260
- result.task #=> <BuildApplication>
1261
- result.context #=> <CMDx::Context>
1262
- result.chain #=> <CMDx::Chain>
1263
-
1264
- # Execution data
1265
- result.state #=> "interrupted"
1266
- result.status #=> "failed"
1267
-
1268
- # Fault data
1269
- result.reason #=> "Build tool not found"
1270
- result.cause #=> <CMDx::FailFault>
1271
- result.metadata #=> { error_code: "BUILD_TOOL.NOT_FOUND" }
1272
- ```
1273
-
1274
- ## Lifecycle Information
1275
-
1276
- Check execution state and status with predicate methods:
1277
-
1278
- ```ruby
1279
- result = BuildApplication.execute(version: "1.2.3")
1280
-
1281
- # State predicates (execution lifecycle)
1282
- result.complete? #=> true (successful completion)
1283
- result.interrupted? #=> false (no interruption)
1284
- result.executed? #=> true (execution finished)
1285
-
1286
- # Status predicates (execution outcome)
1287
- result.success? #=> true (successful execution)
1288
- result.failed? #=> false (no failure)
1289
- result.skipped? #=> false (not skipped)
1290
-
1291
- # Outcome categorization
1292
- result.good? #=> true (success or skipped)
1293
- result.bad? #=> false (skipped or failed)
1294
- ```
1295
-
1296
- ## Outcome Analysis
1297
-
1298
- Get a unified outcome string combining state and status:
1299
-
1300
- ```ruby
1301
- result = BuildApplication.execute(version: "1.2.3")
1302
-
1303
- result.outcome #=> "success" (state and status)
1304
- ```
1305
-
1306
- ## Chain Analysis
1307
-
1308
- Trace fault origins and propagation:
1309
-
1310
- ```ruby
1311
- result = DeploymentWorkflow.execute(app_name: "webapp")
1312
-
1313
- if result.failed?
1314
- # Find the original cause of failure
1315
- if original_failure = result.caused_failure
1316
- puts "Root cause: #{original_failure.task.class.name}"
1317
- puts "Reason: #{original_failure.reason}"
1318
- end
1319
-
1320
- # Find what threw the failure to this result
1321
- if throwing_task = result.threw_failure
1322
- puts "Failure source: #{throwing_task.task.class.name}"
1323
- puts "Reason: #{throwing_task.reason}"
1324
- end
1325
-
1326
- # Failure classification
1327
- result.caused_failure? #=> true if this result was the original cause
1328
- result.threw_failure? #=> true if this result threw a failure
1329
- result.thrown_failure? #=> true if this result received a thrown failure
1330
- end
1331
- ```
1332
-
1333
- ## Index and Position
1334
-
1335
- Results track their position within execution chains:
1336
-
1337
- ```ruby
1338
- result = BuildApplication.execute(version: "1.2.3")
1339
-
1340
- # Position in execution sequence
1341
- result.index #=> 0 (first task in chain)
1342
-
1343
- # Access via chain
1344
- result.chain.results[result.index] == result #=> true
1345
- ```
1346
-
1347
- ## Block Yield
1348
-
1349
- Execute code with direct result access:
1350
-
1351
- ```ruby
1352
- BuildApplication.execute(version: "1.2.3") do |result|
1353
- if result.success?
1354
- notify_deployment_ready(result)
1355
- elsif result.failed?
1356
- handle_build_failure(result)
1357
- else
1358
- log_skip_reason(result)
1359
- end
1360
- end
1361
- ```
1362
-
1363
- ## Handlers
1364
-
1365
- Handle outcomes with functional-style methods. Handlers return the result for chaining:
1366
-
1367
- ```ruby
1368
- result = BuildApplication.execute(version: "1.2.3")
1369
-
1370
- # Status-based handlers
1371
- result
1372
- .handle_success { |result| notify_deployment_ready(result) }
1373
- .handle_failed { |result| handle_build_failure(result) }
1374
- .handle_skipped { |result| log_skip_reason(result) }
1375
-
1376
- # State-based handlers
1377
- result
1378
- .handle_complete { |result| update_build_status(result) }
1379
- .handle_interrupted { |result| cleanup_partial_artifacts(result) }
1380
-
1381
- # Outcome-based handlers
1382
- result
1383
- .handle_good { |result| increment_success_counter(result) }
1384
- .handle_bad { |result| alert_operations_team(result) }
1385
- ```
1386
-
1387
- ## Pattern Matching
1388
-
1389
- Use Ruby 3.0+ pattern matching for elegant outcome handling:
1390
-
1391
- !!! warning "Important"
1392
-
1393
- Pattern matching works with both array and hash deconstruction.
1394
-
1395
- ### Array Pattern
1396
-
1397
- ```ruby
1398
- result = BuildApplication.execute(version: "1.2.3")
1399
-
1400
- case result
1401
- in ["complete", "success"]
1402
- redirect_to build_success_page
1403
- in ["interrupted", "failed"]
1404
- retry_build_with_backoff(result)
1405
- in ["interrupted", "skipped"]
1406
- log_skip_and_continue
1407
- end
1408
- ```
1409
-
1410
- ### Hash Pattern
1411
-
1412
- ```ruby
1413
- result = BuildApplication.execute(version: "1.2.3")
1414
-
1415
- case result
1416
- in { state: "complete", status: "success" }
1417
- celebrate_build_success
1418
- in { status: "failed", metadata: { retryable: true } }
1419
- schedule_build_retry(result)
1420
- in { bad: true, metadata: { reason: String => reason } }
1421
- escalate_build_error("Build failed: #{reason}")
1422
- end
1423
- ```
1424
-
1425
- ### Pattern Guards
1426
-
1427
- ```ruby
1428
- case result
1429
- in { status: "failed", metadata: { attempts: n } } if n < 3
1430
- retry_build_with_delay(result, n * 2)
1431
- in { status: "failed", metadata: { attempts: n } } if n >= 3
1432
- mark_build_permanently_failed(result)
1433
- in { runtime: time } if time > performance_threshold
1434
- investigate_build_performance(result)
1435
- end
1436
- ```
1437
-
1438
- # Outcomes - States
1439
-
1440
- States track where a task is in its execution lifecycleβ€”from creation through completion or interruption.
1441
-
1442
- ## Definitions
1443
-
1444
- | State | Description |
1445
- | ----- | ----------- |
1446
- | `initialized` | Task created but execution not yet started. Default state for new tasks. |
1447
- | `executing` | Task is actively running its business logic. Transient state during execution. |
1448
- | `complete` | Task finished execution successfully without any interruption or halt. |
1449
- | `interrupted` | Task execution was stopped due to a fault, exception, or explicit halt. |
1450
-
1451
- State-Status combinations:
1452
-
1453
- | State | Status | Meaning |
1454
- | ----- | ------ | ------- |
1455
- | `initialized` | `success` | Task created, not yet executed |
1456
- | `executing` | `success` | Task currently running |
1457
- | `complete` | `success` | Task finished successfully |
1458
- | `complete` | `skipped` | Task finished by skipping execution |
1459
- | `interrupted` | `failed` | Task stopped due to failure |
1460
- | `interrupted` | `skipped` | Task stopped by skip condition |
1461
-
1462
- ## Transitions
1463
-
1464
- !!! danger "Caution"
1465
-
1466
- States are managed automaticallyβ€”never modify them manually.
1467
-
1468
- ```ruby
1469
- # Valid state transition flow
1470
- initialized β†’ executing β†’ complete (successful execution)
1471
- initialized β†’ executing β†’ interrupted (skipped/failed execution)
1472
- ```
1473
-
1474
- ## Predicates
1475
-
1476
- Use state predicates to check the current execution lifecycle:
1477
-
1478
- ```ruby
1479
- result = ProcessVideoUpload.execute
1480
-
1481
- # Individual state checks
1482
- result.initialized? #=> false (after execution)
1483
- result.executing? #=> false (after execution)
1484
- result.complete? #=> true (successful completion)
1485
- result.interrupted? #=> false (no interruption)
1486
-
1487
- # State categorization
1488
- result.executed? #=> true (complete OR interrupted)
1489
- ```
1490
-
1491
- ## Handlers
1492
-
1493
- Handle lifecycle events with state-based handlers. Use `handle_executed` for cleanup that runs regardless of outcome:
1494
-
1495
- ```ruby
1496
- result = ProcessVideoUpload.execute
1497
-
1498
- # Individual state handlers
1499
- result
1500
- .handle_complete { |result| send_upload_notification(result) }
1501
- .handle_interrupted { |result| cleanup_temp_files(result) }
1502
- .handle_executed { |result| log_upload_metrics(result) }
1503
- ```
1504
-
1505
- # Outcomes - Statuses
1506
-
1507
- Statuses represent the business outcomeβ€”did the task succeed, skip, or fail? This differs from state, which tracks the execution lifecycle.
1508
-
1509
- ## Definitions
1510
-
1511
- | Status | Description |
1512
- | ------ | ----------- |
1513
- | `success` | Task execution completed successfully with expected business outcome. Default status for all tasks. |
1514
- | `skipped` | Task intentionally stopped execution because conditions weren't met or continuation was unnecessary. |
1515
- | `failed` | Task stopped execution due to business rule violations, validation errors, or exceptions. |
1516
-
1517
- ## Transitions
1518
-
1519
- !!! warning "Important"
1520
-
1521
- Status transitions are final and unidirectional. Once skipped or failed, tasks can't return to success.
1522
-
1523
- ```ruby
1524
- # Valid status transitions
1525
- success β†’ skipped # via skip!
1526
- success β†’ failed # via fail! or exception
1527
-
1528
- # Invalid transitions (will raise errors)
1529
- skipped β†’ success # ❌ Cannot transition
1530
- skipped β†’ failed # ❌ Cannot transition
1531
- failed β†’ success # ❌ Cannot transition
1532
- failed β†’ skipped # ❌ Cannot transition
1533
- ```
1534
-
1535
- ## Predicates
1536
-
1537
- Use status predicates to check execution outcomes:
1538
-
1539
- ```ruby
1540
- result = ProcessNotification.execute
1541
-
1542
- # Individual status checks
1543
- result.success? #=> true/false
1544
- result.skipped? #=> true/false
1545
- result.failed? #=> true/false
1546
-
1547
- # Outcome categorization
1548
- result.good? #=> true if success OR skipped
1549
- result.bad? #=> true if skipped OR failed (not success)
1550
- ```
1551
-
1552
- ## Handlers
1553
-
1554
- Branch business logic with status-based handlers. Use `handle_good` and `handle_bad` for success/skip vs failed outcomes:
1555
-
1556
- ```ruby
1557
- result = ProcessNotification.execute
1558
-
1559
- # Individual status handlers
1560
- result
1561
- .handle_success { |result| mark_notification_sent(result) }
1562
- .handle_skipped { |result| log_notification_skipped(result) }
1563
- .handle_failed { |result| queue_retry_notification(result) }
1564
-
1565
- # Outcome-based handlers
1566
- result
1567
- .handle_good { |result| update_message_stats(result) }
1568
- .handle_bad { |result| track_delivery_failure(result) }
1569
- ```
1570
-
1571
- # Attributes - Definitions
1572
-
1573
- Attributes define your task's interface with automatic validation, type coercion, and accessor generation. They're the contract between callers and your business logic.
1574
-
1575
- ## Declarations
1576
-
1577
- !!! tip
1578
-
1579
- Prefer using the `required` and `optional` alias for `attributes` for brevity and to clearly signal intent.
1580
-
1581
- ### Optional
1582
-
1583
- Optional attributes return `nil` when not provided.
1584
-
1585
- ```ruby
1586
- class ScheduleEvent < CMDx::Task
1587
- attribute :title
1588
- attributes :duration, :location
1589
-
1590
- # Alias for attributes (preferred)
1591
- optional :description
1592
- optional :visibility, :attendees
1593
-
1594
- def work
1595
- title #=> "Team Standup"
1596
- duration #=> 30
1597
- location #=> nil
1598
- description #=> nil
1599
- visibility #=> nil
1600
- attendees #=> ["alice@company.com", "bob@company.com"]
1601
- end
1602
- end
1603
-
1604
- # Attributes passed as keyword arguments
1605
- ScheduleEvent.execute(
1606
- title: "Team Standup",
1607
- duration: 30,
1608
- attendees: ["alice@company.com", "bob@company.com"]
1609
- )
1610
- ```
1611
-
1612
- ### Required
1613
-
1614
- Required attributes must be provided in call arguments or task execution will fail.
1615
-
1616
- ```ruby
1617
- class PublishArticle < CMDx::Task
1618
- attribute :title, required: true
1619
- attributes :content, :author_id, required: true
1620
-
1621
- # Alias for attributes => required: true (preferred)
1622
- required :category
1623
- required :status, :tags
1624
-
1625
- def work
1626
- title #=> "Getting Started with Ruby"
1627
- content #=> "This is a comprehensive guide..."
1628
- author_id #=> 42
1629
- category #=> "programming"
1630
- status #=> :published
1631
- tags #=> ["ruby", "beginner"]
1632
- end
1633
- end
1634
-
1635
- # Attributes passed as keyword arguments
1636
- PublishArticle.execute(
1637
- title: "Getting Started with Ruby",
1638
- content: "This is a comprehensive guide...",
1639
- author_id: 42,
1640
- category: "programming",
1641
- status: :published,
1642
- tags: ["ruby", "beginner"]
1643
- )
1644
- ```
1645
-
1646
- ## Sources
1647
-
1648
- Attributes read from any accessible objectβ€”not just context. Use sources to pull data from models, services, or any callable:
1649
-
1650
- ### Context
1651
-
1652
- ```ruby
1653
- class BackupDatabase < CMDx::Task
1654
- # Default source is :context
1655
- required :database_name
1656
- optional :compression_level
1657
-
1658
- # Explicitly specify context source
1659
- attribute :backup_path, source: :context
1660
-
1661
- def work
1662
- database_name #=> context.database_name
1663
- backup_path #=> context.backup_path
1664
- compression_level #=> context.compression_level
1665
- end
1666
- end
1667
- ```
1668
-
1669
- ### Symbol References
1670
-
1671
- Reference instance methods by symbol for dynamic source values:
1672
-
1673
- ```ruby
1674
- class BackupDatabase < CMDx::Task
1675
- attributes :host, :credentials, source: :database_config
1676
-
1677
- # Access from declared attributes
1678
- attribute :connection_string, source: :credentials
1679
-
1680
- def work
1681
- # Your logic here...
1682
- end
1683
-
1684
- private
1685
-
1686
- def database_config
1687
- @database_config ||= DatabaseConfig.find(context.database_name)
1688
- end
1689
- end
1690
- ```
1691
-
1692
- ### Proc or Lambda
1693
-
1694
- Use anonymous functions for dynamic source values:
1695
-
1696
- ```ruby
1697
- class BackupDatabase < CMDx::Task
1698
- # Proc
1699
- attribute :timestamp, source: proc { Time.current }
1700
-
1701
- # Lambda
1702
- attribute :server, source: -> { Current.server }
1703
- end
1704
- ```
1705
-
1706
- ### Class or Module
1707
-
1708
- For complex source logic, use classes or modules:
1709
-
1710
- ```ruby
1711
- class DatabaseResolver
1712
- def self.call(task)
1713
- Database.find(task.context.database_name)
1714
- end
1715
- end
1716
-
1717
- class BackupDatabase < CMDx::Task
1718
- # Class or Module
1719
- attribute :schema, source: DatabaseResolver
1720
-
1721
- # Instance
1722
- attribute :metadata, source: DatabaseResolver.new
1723
- end
1724
- ```
1725
-
1726
- ## Nesting
1727
-
1728
- Build complex structures with nested attributes. Children inherit their parent as source and support all attribute options:
1729
-
1730
- !!! note
1731
-
1732
- Nested attributes support all features: naming, coercions, validations, defaults, and more.
1733
-
1734
- ```ruby
1735
- class ConfigureServer < CMDx::Task
1736
- # Required parent with required children
1737
- required :network_config do
1738
- required :hostname, :port, :protocol, :subnet
1739
- optional :load_balancer
1740
- attribute :firewall_rules
1741
- end
1742
-
1743
- # Optional parent with conditional children
1744
- optional :ssl_config do
1745
- required :certificate_path, :private_key # Only required if ssl_config provided
1746
- optional :enable_http2, prefix: true
1747
- end
1748
-
1749
- # Multi-level nesting
1750
- attribute :monitoring do
1751
- required :provider
1752
-
1753
- optional :alerting do
1754
- required :threshold_percentage
1755
- optional :notification_channel
1756
- end
1757
- end
1758
-
1759
- def work
1760
- network_config #=> { hostname: "api.company.com" ... }
1761
- hostname #=> "api.company.com"
1762
- load_balancer #=> nil
1763
- end
1764
- end
1765
-
1766
- ConfigureServer.execute(
1767
- server_id: "srv-001",
1768
- network_config: {
1769
- hostname: "api.company.com",
1770
- port: 443,
1771
- protocol: "https",
1772
- subnet: "10.0.1.0/24",
1773
- firewall_rules: "allow_web_traffic"
1774
- },
1775
- monitoring: {
1776
- provider: "datadog",
1777
- alerting: {
1778
- threshold_percentage: 85.0,
1779
- notification_channel: "slack"
1780
- }
1781
- }
1782
- )
1783
- ```
1784
-
1785
- !!! warning "Important"
1786
-
1787
- Child requirements only apply when the parent is providedβ€”perfect for optional structures.
1788
-
1789
- ## Error Handling
1790
-
1791
- Validation failures provide detailed, structured error messages:
1792
-
1793
- !!! note
1794
-
1795
- Nested attributes are only validated when their parent is present and valid.
1796
-
1797
- ```ruby
1798
- class ConfigureServer < CMDx::Task
1799
- required :server_id, :environment
1800
- required :network_config do
1801
- required :hostname, :port
1802
- end
1803
-
1804
- def work
1805
- # Your logic here...
1806
- end
1807
- end
1808
-
1809
- # Missing required top-level attributes
1810
- result = ConfigureServer.execute(server_id: "srv-001")
1811
-
1812
- result.state #=> "interrupted"
1813
- result.status #=> "failed"
1814
- result.reason #=> "Invalid"
1815
- result.metadata #=> {
1816
- # errors: {
1817
- # full_message: "environment is required. network_config is required.",
1818
- # messages: {
1819
- # environment: ["is required"],
1820
- # network_config: ["is required"]
1821
- # }
1822
- # }
1823
- # }
1824
-
1825
- # Missing required nested attributes
1826
- result = ConfigureServer.execute(
1827
- server_id: "srv-001",
1828
- environment: "production",
1829
- network_config: { hostname: "api.company.com" } # Missing port
1830
- )
1831
-
1832
- result.state #=> "interrupted"
1833
- result.status #=> "failed"
1834
- result.reason #=> "Invalid"
1835
- result.metadata #=> {
1836
- # errors: {
1837
- # full_message: "port is required.",
1838
- # messages: {
1839
- # port: ["is required"]
1840
- # }
1841
- # }
1842
- # }
1843
- ```
1844
-
1845
- # Attributes - Naming
1846
-
1847
- Customize accessor method names to avoid conflicts and improve clarity. Affixing changes only the generated methodsβ€”not the original attribute names.
1848
-
1849
- !!! note
1850
-
1851
- Use naming when attributes conflict with existing methods or need better clarity in your code.
1852
-
1853
- ## Prefix
1854
-
1855
- Adds a prefix to the generated accessor method name.
1856
-
1857
- ```ruby
1858
- class GenerateReport < CMDx::Task
1859
- # Dynamic from attribute source
1860
- attribute :template, prefix: true
1861
-
1862
- # Static
1863
- attribute :format, prefix: "report_"
1864
-
1865
- def work
1866
- context_template #=> "monthly_sales"
1867
- report_format #=> "pdf"
1868
- end
1869
- end
1870
-
1871
- # Attributes passed as original attribute names
1872
- GenerateReport.execute(template: "monthly_sales", format: "pdf")
1873
- ```
1874
-
1875
- ## Suffix
1876
-
1877
- Adds a suffix to the generated accessor method name.
1878
-
1879
- ```ruby
1880
- class DeployApplication < CMDx::Task
1881
- # Dynamic from attribute source
1882
- attribute :branch, suffix: true
1883
-
1884
- # Static
1885
- attribute :version, suffix: "_tag"
1886
-
1887
- def work
1888
- branch_context #=> "main"
1889
- version_tag #=> "v1.2.3"
1890
- end
1891
- end
1892
-
1893
- # Attributes passed as original attribute names
1894
- DeployApplication.execute(branch: "main", version: "v1.2.3")
1895
- ```
1896
-
1897
- ## As
1898
-
1899
- Completely renames the generated accessor method.
1900
-
1901
- ```ruby
1902
- class ScheduleMaintenance < CMDx::Task
1903
- attribute :scheduled_at, as: :when
1904
-
1905
- def work
1906
- when #=> <DateTime>
1907
- end
1908
- end
1909
-
1910
- # Attributes passed as original attribute names
1911
- ScheduleMaintenance.execute(scheduled_at: DateTime.new(2024, 12, 15, 2, 0, 0))
1912
- ```
1913
-
1914
- # Attributes - Coercions
1915
-
1916
- Automatically convert inputs to expected types. Coercions handle everything from simple string-to-integer conversions to JSON parsing.
1917
-
1918
- See [Global Configuration](https://github.com/drexed/cmdx/blob/main/docs/getting_started.md#coercions) for custom coercion setup.
1919
-
1920
- ## Usage
1921
-
1922
- Define attribute types to enable automatic coercion:
1923
-
1924
- ```ruby
1925
- class ParseMetrics < CMDx::Task
1926
- # Coerce into a symbol
1927
- attribute :measurement_type, type: :symbol
1928
-
1929
- # Coerce into a rational fallback to big decimal
1930
- attribute :value, type: [:rational, :big_decimal]
1931
-
1932
- # Coerce with options
1933
- attribute :recorded_at, type: :date, strptime: "%m-%d-%Y"
1934
-
1935
- def work
1936
- measurement_type #=> :temperature
1937
- recorded_at #=> <Date 2024-01-23>
1938
- value #=> 98.6 (Float)
1939
- end
1940
- end
1941
-
1942
- ParseMetrics.execute(
1943
- measurement_type: "temperature",
1944
- recorded_at: "01-23-2020",
1945
- value: "98.6"
1946
- )
1947
- ```
1948
-
1949
- !!! tip
1950
-
1951
- Specify multiple coercion types for attributes that could be a variety of value formats. CMDx attempts each type in order until one succeeds.
1952
-
1953
- ## Built-in Coercions
1954
-
1955
- | Type | Options | Description | Examples |
1956
- |------|---------|-------------|----------|
1957
- | `:array` | | Array conversion with JSON support | `"val"` β†’ `["val"]`<br>`"[1,2,3]"` β†’ `[1, 2, 3]` |
1958
- | `:big_decimal` | `:precision` | High-precision decimal | `"123.456"` β†’ `BigDecimal("123.456")` |
1959
- | `:boolean` | | Boolean with text patterns | `"yes"` β†’ `true`, `"no"` β†’ `false` |
1960
- | `:complex` | | Complex numbers | `"1+2i"` β†’ `Complex(1, 2)` |
1961
- | `:date` | `:strptime` | Date objects | `"2024-01-23"` β†’ `Date.new(2024, 1, 23)` |
1962
- | `:datetime` | `:strptime` | DateTime objects | `"2024-01-23 10:30"` β†’ `DateTime.new(2024, 1, 23, 10, 30)` |
1963
- | `:float` | | Floating-point numbers | `"123.45"` β†’ `123.45` |
1964
- | `:hash` | | Hash conversion with JSON support | `'{"a":1}'` β†’ `{"a" => 1}` |
1965
- | `:integer` | | Integer with hex/octal support | `"0xFF"` β†’ `255`, `"077"` β†’ `63` |
1966
- | `:rational` | | Rational numbers | `"1/2"` β†’ `Rational(1, 2)` |
1967
- | `:string` | | String conversion | `123` β†’ `"123"` |
1968
- | `:symbol` | | Symbol conversion | `"abc"` β†’ `:abc` |
1969
- | `:time` | `:strptime` | Time objects | `"10:30:00"` β†’ `Time.new(2024, 1, 23, 10, 30)` |
1970
-
1971
- ## Declarations
1972
-
1973
- !!! warning "Important"
1974
-
1975
- Custom coercions must raise `CMDx::CoercionError` with a descriptive message.
1976
-
1977
- ### Proc or Lambda
1978
-
1979
- Use anonymous functions for simple coercion logic:
1980
-
1981
- ```ruby
1982
- class TransformCoordinates < CMDx::Task
1983
- # Proc
1984
- register :callback, :geolocation, proc do |value, options = {}|
1985
- begin
1986
- Geolocation(value)
1987
- rescue StandardError
1988
- raise CMDx::CoercionError, "could not convert into a geolocation"
1989
- end
1990
- end
1991
-
1992
- # Lambda
1993
- register :callback, :geolocation, ->(value, options = {}) {
1994
- begin
1995
- Geolocation(value)
1996
- rescue StandardError
1997
- raise CMDx::CoercionError, "could not convert into a geolocation"
1998
- end
1999
- }
2000
- end
2001
- ```
2002
-
2003
- ### Class or Module
2004
-
2005
- Register custom coercion logic for specialized type handling:
2006
-
2007
- ```ruby
2008
- class GeolocationCoercion
2009
- def self.call(value, options = {})
2010
- Geolocation(value)
2011
- rescue StandardError
2012
- raise CMDx::CoercionError, "could not convert into a geolocation"
2013
- end
2014
- end
2015
-
2016
- class TransformCoordinates < CMDx::Task
2017
- register :coercion, :geolocation, GeolocationCoercion
2018
-
2019
- attribute :latitude, type: :geolocation
2020
- end
2021
- ```
2022
-
2023
- ## Removals
2024
-
2025
- Remove unwanted coercions:
2026
-
2027
- !!! warning
2028
-
2029
- Each `deregister` call removes one coercion. Use multiple calls for batch removals.
2030
-
2031
- ```ruby
2032
- class TransformCoordinates < CMDx::Task
2033
- deregister :coercion, :geolocation
2034
- end
2035
- ```
2036
-
2037
- ## Error Handling
2038
-
2039
- Coercion failures provide detailed error information including attribute paths, attempted types, and specific failure reasons:
2040
-
2041
- ```ruby
2042
- class AnalyzePerformance < CMDx::Task
2043
- attribute :iterations, type: :integer
2044
- attribute :score, type: [:float, :big_decimal]
2045
-
2046
- def work
2047
- # Your logic here...
2048
- end
2049
- end
2050
-
2051
- result = AnalyzePerformance.execute(
2052
- iterations: "not-a-number",
2053
- score: "invalid-float"
2054
- )
2055
-
2056
- result.state #=> "interrupted"
2057
- result.status #=> "failed"
2058
- result.reason #=> "Invalid"
2059
- result.metadata #=> {
2060
- # errors: {
2061
- # full_message: "iterations could not coerce into an integer. score could not coerce into one of: float, big_decimal.",
2062
- # messages: {
2063
- # iterations: ["could not coerce into an integer"],
2064
- # score: ["could not coerce into one of: float, big_decimal"]
2065
- # }
2066
- # }
2067
- # }
2068
- ```
2069
-
2070
- # Attributes - Validations
2071
-
2072
- Ensure inputs meet requirements before execution. Validations run after coercions, giving you declarative data integrity checks.
2073
-
2074
- See [Global Configuration](https://github.com/drexed/cmdx/blob/main/docs/getting_started.md#validators) for custom validator setup.
2075
-
2076
- ## Usage
2077
-
2078
- Define validation rules on attributes to enforce data requirements:
2079
-
2080
- ```ruby
2081
- class ProcessSubscription < CMDx::Task
2082
- # Required field with presence validation
2083
- attribute :user_id, presence: true
2084
-
2085
- # String with length constraints
2086
- attribute :preferences, length: { minimum: 10, maximum: 500 }
2087
-
2088
- # Numeric range validation
2089
- attribute :tier_level, inclusion: { in: 1..5 }
2090
-
2091
- # Format validation for email
2092
- attribute :contact_email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
2093
-
2094
- def work
2095
- user_id #=> "98765"
2096
- preferences #=> "Send weekly digest emails"
2097
- tier_level #=> 3
2098
- contact_email #=> "user@company.com"
2099
- end
2100
- end
2101
-
2102
- ProcessSubscription.execute(
2103
- user_id: "98765",
2104
- preferences: "Send weekly digest emails",
2105
- tier_level: 3,
2106
- contact_email: "user@company.com"
2107
- )
2108
- ```
2109
-
2110
- !!! tip
2111
-
2112
- Validations run after coercions, so you can validate the final coerced values rather than raw input.
2113
-
2114
- ## Built-in Validators
2115
-
2116
- ### Common Options
2117
-
2118
- This list of options is available to all validators:
2119
-
2120
- | Option | Description |
2121
- |--------|-------------|
2122
- | `:allow_nil` | Skip validation when value is `nil` |
2123
- | `:if` | Symbol, proc, lambda, or callable determining when to validate |
2124
- | `:unless` | Symbol, proc, lambda, or callable determining when to skip validation |
2125
- | `:message` | Custom error message for validation failures |
2126
-
2127
- ### Exclusion
2128
-
2129
- ```ruby
2130
- class ProcessProduct < CMDx::Task
2131
- attribute :status, exclusion: { in: %w[recalled archived] }
2132
-
2133
- def work
2134
- # Your logic here...
2135
- end
2136
- end
2137
- ```
2138
-
2139
- | Options | Description |
2140
- |---------|-------------|
2141
- | `:in` | The collection of forbidden values or range |
2142
- | `:within` | Alias for :in option |
2143
- | `:of_message` | Custom message for discrete value exclusions |
2144
- | `:in_message` | Custom message for range-based exclusions |
2145
- | `:within_message` | Alias for :in_message option |
2146
-
2147
- ### Format
2148
-
2149
- ```ruby
2150
- class ProcessProduct < CMDx::Task
2151
- attribute :sku, format: /\A[A-Z]{3}-[0-9]{4}\z/
2152
-
2153
- attribute :sku, format: { with: /\A[A-Z]{3}-[0-9]{4}\z/ }
2154
-
2155
- def work
2156
- # Your logic here...
2157
- end
2158
- end
2159
- ```
2160
-
2161
- | Options | Description |
2162
- |---------|-------------|
2163
- | `regexp` | Alias for :with option |
2164
- | `:with` | Regex pattern that the value must match |
2165
- | `:without` | Regex pattern that the value must not match |
2166
-
2167
- ### Inclusion
2168
-
2169
- ```ruby
2170
- class ProcessProduct < CMDx::Task
2171
- attribute :availability, inclusion: { in: %w[available limited] }
2172
-
2173
- def work
2174
- # Your logic here...
2175
- end
2176
- end
2177
- ```
2178
-
2179
- | Options | Description |
2180
- |---------|-------------|
2181
- | `:in` | The collection of allowed values or range |
2182
- | `:within` | Alias for :in option |
2183
- | `:of_message` | Custom message for discrete value inclusions |
2184
- | `:in_message` | Custom message for range-based inclusions |
2185
- | `:within_message` | Alias for :in_message option |
2186
-
2187
- ### Length
2188
-
2189
- ```ruby
2190
- class CreateBlogPost < CMDx::Task
2191
- attribute :title, length: { within: 5..100 }
2192
-
2193
- def work
2194
- # Your logic here...
2195
- end
2196
- end
2197
- ```
2198
-
2199
- | Options | Description |
2200
- |---------|-------------|
2201
- | `:within` | Range that the length must fall within (inclusive) |
2202
- | `:not_within` | Range that the length must not fall within |
2203
- | `:in` | Alias for :within |
2204
- | `:not_in` | Range that the length must not fall within |
2205
- | `:min` | Minimum allowed length |
2206
- | `:max` | Maximum allowed length |
2207
- | `:is` | Exact required length |
2208
- | `:is_not` | Length that is not allowed |
2209
- | `:within_message` | Custom message for within/range validations |
2210
- | `:in_message` | Custom message for :in validation |
2211
- | `:not_within_message` | Custom message for not_within validation |
2212
- | `:not_in_message` | Custom message for not_in validation |
2213
- | `:min_message` | Custom message for minimum length validation |
2214
- | `:max_message` | Custom message for maximum length validation |
2215
- | `:is_message` | Custom message for exact length validation |
2216
- | `:is_not_message` | Custom message for is_not validation |
2217
-
2218
- ### Numeric
2219
-
2220
- ```ruby
2221
- class CreateBlogPost < CMDx::Task
2222
- attribute :word_count, numeric: { min: 100 }
2223
-
2224
- def work
2225
- # Your logic here...
2226
- end
2227
- end
2228
- ```
2229
-
2230
- | Options | Description |
2231
- |---------|-------------|
2232
- | `:within` | Range that the value must fall within (inclusive) |
2233
- | `:not_within` | Range that the value must not fall within |
2234
- | `:in` | Alias for :within option |
2235
- | `:not_in` | Alias for :not_within option |
2236
- | `:min` | Minimum allowed value (inclusive, >=) |
2237
- | `:max` | Maximum allowed value (inclusive, <=) |
2238
- | `:is` | Exact value that must match |
2239
- | `:is_not` | Value that must not match |
2240
- | `:within_message` | Custom message for range validations |
2241
- | `:not_within_message` | Custom message for exclusion validations |
2242
- | `:min_message` | Custom message for minimum validation |
2243
- | `:max_message` | Custom message for maximum validation |
2244
- | `:is_message` | Custom message for exact match validation |
2245
- | `:is_not_message` | Custom message for exclusion validation |
2246
-
2247
- ### Presence
2248
-
2249
- ```ruby
2250
- class CreateBlogPost < CMDx::Task
2251
- attribute :content, presence: true
2252
-
2253
- attribute :content, presence: { message: "cannot be blank" }
2254
-
2255
- def work
2256
- # Your logic here...
2257
- end
2258
- end
2259
- ```
2260
-
2261
- | Options | Description |
2262
- |---------|-------------|
2263
- | `true` | Ensures value is not nil, empty string, or whitespace |
2264
-
2265
- ## Declarations
2266
-
2267
- !!! warning "Important"
2268
-
2269
- Custom validators must raise `CMDx::ValidationError` with a descriptive message.
2270
-
2271
- ### Proc or Lambda
2272
-
2273
- Use anonymous functions for simple validation logic:
2274
-
2275
- ```ruby
2276
- class SetupApplication < CMDx::Task
2277
- # Proc
2278
- register :validator, :api_key, proc do |value, options = {}|
2279
- unless value.match?(/\A[a-zA-Z0-9]{32}\z/)
2280
- raise CMDx::ValidationError, "invalid API key format"
2281
- end
2282
- end
2283
-
2284
- # Lambda
2285
- register :validator, :api_key, ->(value, options = {}) {
2286
- unless value.match?(/\A[a-zA-Z0-9]{32}\z/)
2287
- raise CMDx::ValidationError, "invalid API key format"
2288
- end
2289
- }
2290
- end
2291
- ```
2292
-
2293
- ### Class or Module
2294
-
2295
- Register custom validation logic for specialized requirements:
2296
-
2297
- ```ruby
2298
- class ApiKeyValidator
2299
- def self.call(value, options = {})
2300
- unless value.match?(/\A[a-zA-Z0-9]{32}\z/)
2301
- raise CMDx::ValidationError, "invalid API key format"
2302
- end
2303
- end
2304
- end
2305
-
2306
- class SetupApplication < CMDx::Task
2307
- register :validator, :api_key, ApiKeyValidator
2308
-
2309
- attribute :access_key, api_key: true
2310
- end
2311
- ```
2312
-
2313
- ## Removals
2314
-
2315
- Remove unwanted validators:
2316
-
2317
- !!! warning
2318
-
2319
- Each `deregister` call removes one validator. Use multiple calls for batch removals.
2320
-
2321
- ```ruby
2322
- class SetupApplication < CMDx::Task
2323
- deregister :validator, :api_key
2324
- end
2325
- ```
2326
-
2327
- ## Error Handling
2328
-
2329
- Validation failures provide detailed, structured error messages:
2330
-
2331
- ```ruby
2332
- class CreateProject < CMDx::Task
2333
- attribute :project_name, presence: true, length: { minimum: 3, maximum: 50 }
2334
- attribute :budget, numeric: { greater_than: 1000, less_than: 1000000 }
2335
- attribute :priority, inclusion: { in: [:low, :medium, :high] }
2336
- attribute :contact_email, format: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
2337
-
2338
- def work
2339
- # Your logic here...
2340
- end
2341
- end
2342
-
2343
- result = CreateProject.execute(
2344
- project_name: "AB", # Too short
2345
- budget: 500, # Too low
2346
- priority: :urgent, # Not in allowed list
2347
- contact_email: "invalid-email" # Invalid format
2348
- )
2349
-
2350
- result.state #=> "interrupted"
2351
- result.status #=> "failed"
2352
- result.reason #=> "Invalid"
2353
- result.metadata #=> {
2354
- # errors: {
2355
- # full_message: "project_name is too short (minimum is 3 characters). budget must be greater than 1000. priority is not included in the list. contact_email is invalid.",
2356
- # messages: {
2357
- # project_name: ["is too short (minimum is 3 characters)"],
2358
- # budget: ["must be greater than 1000"],
2359
- # priority: ["is not included in the list"],
2360
- # contact_email: ["is invalid"]
2361
- # }
2362
- # }
2363
- # }
2364
- ```
2365
-
2366
- # Attributes - Defaults
2367
-
2368
- Provide fallback values for optional attributes. Defaults kick in when values aren't provided or are `nil`.
2369
-
2370
- ## Declarations
2371
-
2372
- Defaults work seamlessly with coercions, validations, and nested attributes:
2373
-
2374
- ### Static Values
2375
-
2376
- ```ruby
2377
- class OptimizeDatabase < CMDx::Task
2378
- attribute :strategy, default: :incremental
2379
- attribute :level, default: "basic"
2380
- attribute :notify_admin, default: true
2381
- attribute :timeout_minutes, default: 30
2382
- attribute :indexes, default: []
2383
- attribute :options, default: {}
2384
-
2385
- def work
2386
- strategy #=> :incremental
2387
- level #=> "basic"
2388
- notify_admin #=> true
2389
- timeout_minutes #=> 30
2390
- indexes #=> []
2391
- options #=> {}
2392
- end
2393
- end
2394
- ```
2395
-
2396
- ### Symbol References
2397
-
2398
- Reference instance methods by symbol for dynamic default values:
2399
-
2400
- ```ruby
2401
- class ProcessAnalytics < CMDx::Task
2402
- attribute :granularity, default: :default_granularity
2403
-
2404
- def work
2405
- # Your logic here...
2406
- end
2407
-
2408
- private
2409
-
2410
- def default_granularity
2411
- Current.user.premium? ? "hourly" : "daily"
2412
- end
2413
- end
2414
- ```
2415
-
2416
- ### Proc or Lambda
2417
-
2418
- Use anonymous functions for dynamic default values:
2419
-
2420
- ```ruby
2421
- class CacheContent < CMDx::Task
2422
- # Proc
2423
- attribute :expire_hours, default: proc { Current.tenant.cache_duration || 24 }
2424
-
2425
- # Lambda
2426
- attribute :compression, default: -> { Current.tenant.premium? ? "gzip" : "none" }
2427
- end
2428
- ```
2429
-
2430
- ## Coercions and Validations
2431
-
2432
- Defaults follow the same coercion and validation rules as provided values:
2433
-
2434
- ```ruby
2435
- class ScheduleBackup < CMDx::Task
2436
- # Coercions
2437
- attribute :retention_days, default: "7", type: :integer
2438
-
2439
- # Validations
2440
- optional :frequency, default: "daily", inclusion: { in: %w[hourly daily weekly monthly] }
2441
- end
2442
- ```
2443
-
2444
- # Attributes - Transformations
2445
-
2446
- Modify attribute values after coercion but before validation. Perfect for normalization, formatting, and data cleanup.
2447
-
2448
- ## Declarations
2449
-
2450
- ### Symbol References
2451
-
2452
- Reference instance methods by symbol for dynamic value transformations:
2453
-
2454
- ```ruby
2455
- class ProcessAnalytics < CMDx::Task
2456
- attribute :options, transform: :compact_blank
2457
- end
2458
- ```
2459
-
2460
- ### Proc or Lambda
2461
-
2462
- Use anonymous functions for dynamic value transformations:
2463
-
2464
- ```ruby
2465
- class CacheContent < CMDx::Task
2466
- # Proc
2467
- attribute :expire_hours, transform: proc { |v| v * 2 }
2468
-
2469
- # Lambda
2470
- attribute :compression, transform: ->(v) { v.to_s.upcase.strip[0..2] }
2471
- end
2472
- ```
2473
-
2474
- ### Class or Module
2475
-
2476
- Use any object that responds to `call` for reusable transformation logic:
2477
-
2478
- ```ruby
2479
- class EmailNormalizer
2480
- def call(value)
2481
- value.to_s.downcase.strip
2482
- end
2483
- end
2484
-
2485
- class ProcessContacts < CMDx::Task
2486
- # Class or Module
2487
- attribute :email, transform: EmailNormalizer
2488
-
2489
- # Instance
2490
- attribute :email, transform: EmailNormalizer.new
2491
- end
2492
- ```
2493
-
2494
- ## Validations
2495
-
2496
- Validations run on transformed values, ensuring data consistency:
2497
-
2498
- ```ruby
2499
- class ScheduleBackup < CMDx::Task
2500
- # Coercions
2501
- attribute :retention_days, type: :integer, transform: proc { |v| v.clamp(1, 5) }
2502
-
2503
- # Validations
2504
- optional :frequency, transform: :downcase, inclusion: { in: %w[hourly daily weekly monthly] }
2505
- end
2506
- ```
2507
-
2508
- # Callbacks
2509
-
2510
- Run custom logic at specific points during task execution. Callbacks have full access to task context and results, making them perfect for logging, notifications, cleanup, and more.
2511
-
2512
- See [Global Configuration](https://github.com/drexed/cmdx/blob/main/docs/getting_started.md#callbacks) for framework-wide callback setup.
2513
-
2514
- !!! warning "Important"
2515
-
2516
- Callbacks execute in declaration order (FIFO). Multiple callbacks of the same type run sequentially.
2517
-
2518
- ## Available Callbacks
2519
-
2520
- Callbacks execute in a predictable lifecycle order:
2521
-
2522
- ```ruby
2523
- 1. before_validation # Pre-validation setup
2524
- 2. before_execution # Prepare for execution
2525
-
2526
- # --- Task#work executes ---
2527
-
2528
- 3. on_[complete|interrupted] # State-based (execution lifecycle)
2529
- 4. on_executed # Always runs after work completes
2530
- 5. on_[success|skipped|failed] # Status-based (business outcome)
2531
- 6. on_[good|bad] # Outcome-based (success/skip vs fail)
2532
- ```
2533
-
2534
- ## Declarations
2535
-
2536
- ### Symbol References
2537
-
2538
- Reference instance methods by symbol for simple callback logic:
2539
-
2540
- ```ruby
2541
- class ProcessBooking < CMDx::Task
2542
- before_execution :find_reservation
2543
-
2544
- # Batch declarations (works for any type)
2545
- on_complete :notify_guest, :update_availability
2546
-
2547
- def work
2548
- # Your logic here...
2549
- end
2550
-
2551
- private
2552
-
2553
- def find_reservation
2554
- @reservation ||= Reservation.find(context.reservation_id)
2555
- end
2556
-
2557
- def notify_guest
2558
- GuestNotifier.call(context.guest, result)
2559
- end
2560
-
2561
- def update_availability
2562
- AvailabilityService.update(context.room_ids, result)
2563
- end
2564
- end
2565
- ```
2566
-
2567
- ### Proc or Lambda
2568
-
2569
- Use anonymous functions for inline callback logic:
2570
-
2571
- ```ruby
2572
- class ProcessBooking < CMDx::Task
2573
- # Proc
2574
- on_interrupted proc { ReservationSystem.pause! }
2575
-
2576
- # Lambda
2577
- on_complete -> { ReservationSystem.resume! }
2578
- end
2579
- ```
2580
-
2581
- ### Class or Module
2582
-
2583
- Implement reusable callback logic in dedicated modules and classes:
2584
-
2585
- ```ruby
2586
- class BookingConfirmationCallback
2587
- def call(task)
2588
- if task.result.success?
2589
- MessagingApi.send_confirmation(task.context.guest)
2590
- else
2591
- MessagingApi.send_issue_alert(task.context.manager)
2592
- end
2593
- end
2594
- end
2595
-
2596
- class ProcessBooking < CMDx::Task
2597
- # Class or Module
2598
- on_success BookingConfirmationCallback
2599
-
2600
- # Instance
2601
- on_interrupted BookingConfirmationCallback.new
2602
- end
2603
- ```
2604
-
2605
- ### Conditional Execution
2606
-
2607
- Control callback execution with conditional logic:
2608
-
2609
- ```ruby
2610
- class MessagingPermissionCheck
2611
- def call(task)
2612
- task.context.guest.can?(:receive_messages)
2613
- end
2614
- end
2615
-
2616
- class ProcessBooking < CMDx::Task
2617
- # If and/or Unless
2618
- before_execution :notify_guest, if: :messaging_enabled?, unless: :messaging_blocked?
2619
-
2620
- # Proc
2621
- on_failure :increment_failure, if: -> { Rails.env.production? && self.class.name.include?("Legacy") }
2622
-
2623
- # Lambda
2624
- on_success :ping_housekeeping, if: proc { context.rooms_need_cleaning? }
2625
-
2626
- # Class or Module
2627
- on_complete :send_confirmation, unless: MessagingPermissionCheck
2628
-
2629
- # Instance
2630
- on_complete :send_confirmation, if: MessagingPermissionCheck.new
2631
-
2632
- def work
2633
- # Your logic here...
2634
- end
2635
-
2636
- private
2637
-
2638
- def messaging_enabled?
2639
- context.guest.messaging_preference == true
2640
- end
2641
-
2642
- def messaging_blocked?
2643
- context.guest.communication_status == :blocked
2644
- end
2645
- end
2646
- ```
2647
-
2648
- ## Callback Removal
2649
-
2650
- Remove unwanted callbacks dynamically:
2651
-
2652
- !!! warning "Important"
2653
-
2654
- Each `deregister` call removes one callback. Use multiple calls for batch removals.
2655
-
2656
- ```ruby
2657
- class ProcessBooking < CMDx::Task
2658
- # Symbol
2659
- deregister :callback, :before_execution, :notify_guest
2660
-
2661
- # Class or Module (no instances)
2662
- deregister :callback, :on_complete, BookingConfirmationCallback
2663
- end
2664
- ```
2665
-
2666
- # Middlewares
2667
-
2668
- Wrap task execution with middleware for cross-cutting concerns like authentication, caching, timeouts, and monitoring. Think Rack middleware, but for your business logic.
2669
-
2670
- See [Global Configuration](https://github.com/drexed/cmdx/blob/main/docs/getting_started.md#middlewares) for framework-wide setup.
2671
-
2672
- ## Execution Order
2673
-
2674
- Middleware wraps task execution in layers, like an onion:
2675
-
2676
- !!! note
2677
-
2678
- First registered = outermost wrapper. They execute in registration order.
2679
-
2680
- ```ruby
2681
- class ProcessCampaign < CMDx::Task
2682
- register :middleware, AuditMiddleware # 1st: outermost wrapper
2683
- register :middleware, AuthorizationMiddleware # 2nd: middle wrapper
2684
- register :middleware, CacheMiddleware # 3rd: innermost wrapper
2685
-
2686
- def work
2687
- # Your logic here...
2688
- end
2689
- end
2690
-
2691
- # Execution flow:
2692
- # 1. AuditMiddleware (before)
2693
- # 2. AuthorizationMiddleware (before)
2694
- # 3. CacheMiddleware (before)
2695
- # 4. [task execution]
2696
- # 5. CacheMiddleware (after)
2697
- # 6. AuthorizationMiddleware (after)
2698
- # 7. AuditMiddleware (after)
2699
- ```
2700
-
2701
- ## Declarations
2702
-
2703
- ### Proc or Lambda
2704
-
2705
- Use anonymous functions for simple middleware logic:
2706
-
2707
- ```ruby
2708
- class ProcessCampaign < CMDx::Task
2709
- # Proc
2710
- register :middleware, proc do |task, options, &block|
2711
- result = block.call
2712
- Analytics.track(result.status)
2713
- result
2714
- end
2715
-
2716
- # Lambda
2717
- register :middleware, ->(task, options, &block) {
2718
- result = block.call
2719
- Analytics.track(result.status)
2720
- result
2721
- }
2722
- end
2723
- ```
2724
-
2725
- ### Class or Module
2726
-
2727
- For complex middleware logic, use classes or modules:
2728
-
2729
- ```ruby
2730
- class TelemetryMiddleware
2731
- def call(task, options)
2732
- result = yield
2733
- Telemetry.record(result.status)
2734
- ensure
2735
- result # Always return result
2736
- end
2737
- end
2738
-
2739
- class ProcessCampaign < CMDx::Task
2740
- # Class or Module
2741
- register :middleware, TelemetryMiddleware
2742
-
2743
- # Instance
2744
- register :middleware, TelemetryMiddleware.new
2745
-
2746
- # With options
2747
- register :middleware, MonitoringMiddleware, service_key: ENV["MONITORING_KEY"]
2748
- register :middleware, MonitoringMiddleware.new(ENV["MONITORING_KEY"])
2749
- end
2750
- ```
2751
-
2752
- ## Removals
2753
-
2754
- Remove class or module-based middleware globally or per-task:
2755
-
2756
- !!! warning
2757
-
2758
- Each `deregister` call removes one middleware. Use multiple calls for batch removals.
2759
-
2760
- ```ruby
2761
- class ProcessCampaign < CMDx::Task
2762
- # Class or Module (no instances)
2763
- deregister :middleware, TelemetryMiddleware
2764
- end
2765
- ```
2766
-
2767
- ## Built-in
2768
-
2769
- ### Timeout
2770
-
2771
- Prevent tasks from running too long:
2772
-
2773
- ```ruby
2774
- class ProcessReport < CMDx::Task
2775
- # Default timeout: 3 seconds
2776
- register :middleware, CMDx::Middlewares::Timeout
2777
-
2778
- # Seconds (takes Numeric, Symbol, Proc, Lambda, Class, Module)
2779
- register :middleware, CMDx::Middlewares::Timeout, seconds: :max_processing_time
2780
-
2781
- # If or Unless (takes Symbol, Proc, Lambda, Class, Module)
2782
- register :middleware, CMDx::Middlewares::Timeout, unless: -> { self.class.name.include?("Quick") }
2783
-
2784
- def work
2785
- # Your logic here...
2786
- end
2787
-
2788
- private
2789
-
2790
- def max_processing_time
2791
- Rails.env.production? ? 2 : 10
2792
- end
2793
- end
2794
-
2795
- # Slow task
2796
- result = ProcessReport.execute
2797
-
2798
- result.state #=> "interrupted"
2799
- result.status #=> "failure"
2800
- result.reason #=> "[CMDx::TimeoutError] execution exceeded 3 seconds"
2801
- result.cause #=> <CMDx::TimeoutError>
2802
- result.metadata #=> { limit: 3 }
2803
- ```
2804
-
2805
- ### Correlate
2806
-
2807
- Add correlation IDs for distributed tracing and request tracking:
2808
-
2809
- ```ruby
2810
- class ProcessExport < CMDx::Task
2811
- # Default correlation ID generation
2812
- register :middleware, CMDx::Middlewares::Correlate
2813
-
2814
- # Seconds (takes Object, Symbol, Proc, Lambda, Class, Module)
2815
- register :middleware, CMDx::Middlewares::Correlate, id: proc { |task| task.context.session_id }
2816
-
2817
- # If or Unless (takes Symbol, Proc, Lambda, Class, Module)
2818
- register :middleware, CMDx::Middlewares::Correlate, if: :correlation_enabled?
2819
-
2820
- def work
2821
- # Your logic here...
2822
- end
2823
-
2824
- private
2825
-
2826
- def correlation_enabled?
2827
- ENV["CORRELATION_ENABLED"] == "true"
2828
- end
2829
- end
2830
-
2831
- result = ProcessExport.execute
2832
- result.metadata #=> { correlation_id: "550e8400-e29b-41d4-a716-446655440000" }
2833
- ```
2834
-
2835
- ### Runtime
2836
-
2837
- Track task execution time in milliseconds using a monotonic clock:
2838
-
2839
- ```ruby
2840
- class PerformanceMonitoringCheck
2841
- def call(task)
2842
- task.context.tenant.monitoring_enabled?
2843
- end
2844
- end
2845
-
2846
- class ProcessExport < CMDx::Task
2847
- # Default timeout is 3 seconds
2848
- register :middleware, CMDx::Middlewares::Runtime
2849
-
2850
- # If or Unless (takes Symbol, Proc, Lambda, Class, Module)
2851
- register :middleware, CMDx::Middlewares::Runtime, if: PerformanceMonitoringCheck
2852
- end
2853
-
2854
- result = ProcessExport.execute
2855
- result.metadata #=> { runtime: 1247 } (ms)
2856
- ```
2857
-
2858
- # Logging
2859
-
2860
- CMDx automatically logs every task execution with structured data, making debugging and monitoring effortless. Choose from multiple formatters to match your logging infrastructure.
2861
-
2862
- ## Formatters
2863
-
2864
- Choose the format that works best for your logging system:
2865
-
2866
- | Formatter | Use Case | Output Style |
2867
- |-----------|----------|--------------|
2868
- | `Line` | Traditional logging | Single-line format |
2869
- | `Json` | Structured systems | Compact JSON |
2870
- | `KeyValue` | Log parsing | `key=value` pairs |
2871
- | `Logstash` | ELK stack | JSON with @version/@timestamp |
2872
- | `Raw` | Minimal output | Message content only |
2873
-
2874
- Sample output:
2875
-
2876
- ```log
2877
- <!-- Success (INFO level) -->
2878
- I, [2022-07-17T18:43:15.000000 #3784] INFO -- GenerateInvoice:
2879
- index=0 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="GenerateInvoice" state="complete" status="success" metadata={runtime: 187}
2880
-
2881
- <!-- Skipped (WARN level) -->
2882
- W, [2022-07-17T18:43:15.000000 #3784] WARN -- ValidateCustomer:
2883
- index=1 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="ValidateCustomer" state="interrupted" status="skipped" reason="Customer already validated"
2884
-
2885
- <!-- Failed (ERROR level) -->
2886
- E, [2022-07-17T18:43:15.000000 #3784] ERROR -- CalculateTax:
2887
- index=2 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="CalculateTax" state="interrupted" status="failed" metadata={error_code: "TAX_SERVICE_UNAVAILABLE"}
2888
-
2889
- <!-- Failed Chain -->
2890
- E, [2022-07-17T18:43:15.000000 #3784] ERROR -- BillingWorkflow:
2891
- index=3 chain_id="018c2b95-b764-7615-a924-cc5b910ed1e5" type="Task" class="BillingWorkflow" state="interrupted" status="failed" caused_failure={index: 2, class: "CalculateTax", status: "failed"} threw_failure={index: 1, class: "ValidateCustomer", status: "failed"}
2892
- ```
2893
-
2894
- !!! tip
2895
-
2896
- Use logging as a low-level event stream to track all tasks in a request. Combine with correlation for powerful distributed tracing.
2897
-
2898
- ## Structure
2899
-
2900
- Every log entry includes rich metadata. Available fields depend on execution context and outcome.
2901
-
2902
- ### Core Fields
2903
-
2904
- | Field | Description | Example |
2905
- |-------|-------------|---------|
2906
- | `severity` | Log level | `INFO`, `WARN`, `ERROR` |
2907
- | `timestamp` | ISO 8601 execution time | `2022-07-17T18:43:15.000000` |
2908
- | `pid` | Process ID | `3784` |
2909
-
2910
- ### Task Information
2911
-
2912
- | Field | Description | Example |
2913
- |-------|-------------|---------|
2914
- | `index` | Execution sequence position | `0`, `1`, `2` |
2915
- | `chain_id` | Unique execution chain ID | `018c2b95-b764-7615...` |
2916
- | `type` | Execution unit type | `Task`, `Workflow` |
2917
- | `class` | Task class name | `GenerateInvoiceTask` |
2918
- | `id` | Unique task instance ID | `018c2b95-b764-7615...` |
2919
- | `tags` | Custom categorization | `["billing", "financial"]` |
2920
-
2921
- ### Execution Data
2922
-
2923
- | Field | Description | Example |
2924
- |-------|-------------|---------|
2925
- | `state` | Lifecycle state | `complete`, `interrupted` |
2926
- | `status` | Business outcome | `success`, `skipped`, `failed` |
2927
- | `outcome` | Final classification | `success`, `interrupted` |
2928
- | `metadata` | Custom task data | `{order_id: 123, amount: 99.99}` |
2929
-
2930
- ### Failure Chain
2931
-
2932
- | Field | Description |
2933
- |-------|-------------|
2934
- | `reason` | Reason given for the stoppage |
2935
- | `caused` | Cause exception details |
2936
- | `caused_failure` | Original failing task details |
2937
- | `threw_failure` | Task that propagated the failure |
2938
-
2939
- ## Usage
2940
-
2941
- Access the framework logger directly within tasks:
2942
-
2943
- ```ruby
2944
- class ProcessSubscription < CMDx::Task
2945
- def work
2946
- logger.debug { "Activated feature flags: #{Features.active_flags}" }
2947
- # Your logic here...
2948
- logger.info("Subscription processed")
2949
- end
2950
- end
2951
- ```
2952
-
2953
- # Internationalization (i18n)
2954
-
2955
- CMDx supports 90+ languages out of the box for all error messages, validations, coercions, and faults. Error messages automatically adapt to the current `I18n.locale`, making it easy to build applications for global audiences.
2956
-
2957
- ## Usage
2958
-
2959
- All error messages are automatically localized based on your current locale:
2960
-
2961
- ```ruby
2962
- class ProcessQuote < CMDx::Task
2963
- attribute :price, type: :float
2964
-
2965
- def work
2966
- # Your logic here...
2967
- end
2968
- end
2969
-
2970
- I18n.with_locale(:fr) do
2971
- result = ProcessQuote.execute(price: "invalid")
2972
- result.metadata[:messages][:price] #=> ["impossible de contraindre en float"]
2973
- end
2974
- ```
2975
-
2976
- ## Configuration
2977
-
2978
- CMDx uses the `I18n` gem for localization. In Rails, locales load automatically.
2979
-
2980
- ### Copy Locale Files
2981
-
2982
- Copy locale files to your Rails application's `config/locales` directory:
2983
-
2984
- ```bash
2985
- rails generate cmdx:locale [LOCALE]
2986
-
2987
- # Eg: generate french locale
2988
- rails generate cmdx:locale fr
2989
- ```
2990
-
2991
- ### Available Locales
2992
-
2993
- - af - Afrikaans
2994
- - ar - Arabic
2995
- - az - Azerbaijani
2996
- - be - Belarusian
2997
- - bg - Bulgarian
2998
- - bn - Bengali
2999
- - bs - Bosnian
3000
- - ca - Catalan
3001
- - cnr - Montenegrin
3002
- - cs - Czech
3003
- - cy - Welsh
3004
- - da - Danish
3005
- - de - German
3006
- - dz - Dzongkha
3007
- - el - Greek
3008
- - en - English
3009
- - eo - Esperanto
3010
- - es - Spanish
3011
- - et - Estonian
3012
- - eu - Basque
3013
- - fa - Persian
3014
- - fi - Finnish
3015
- - fr - French
3016
- - fy - Western Frisian
3017
- - gd - Scottish Gaelic
3018
- - gl - Galician
3019
- - he - Hebrew
3020
- - hi - Hindi
3021
- - hr - Croatian
3022
- - hu - Hungarian
3023
- - hy - Armenian
3024
- - id - Indonesian
3025
- - is - Icelandic
3026
- - it - Italian
3027
- - ja - Japanese
3028
- - ka - Georgian
3029
- - kk - Kazakh
3030
- - km - Khmer
3031
- - kn - Kannada
3032
- - ko - Korean
3033
- - lb - Luxembourgish
3034
- - lo - Lao
3035
- - lt - Lithuanian
3036
- - lv - Latvian
3037
- - mg - Malagasy
3038
- - mk - Macedonian
3039
- - ml - Malayalam
3040
- - mn - Mongolian
3041
- - mr-IN - Marathi (India)
3042
- - ms - Malay
3043
- - nb - Norwegian BokmΓ₯l
3044
- - ne - Nepali
3045
- - nl - Dutch
3046
- - nn - Norwegian Nynorsk
3047
- - oc - Occitan
3048
- - or - Odia
3049
- - pa - Punjabi
3050
- - pl - Polish
3051
- - pt - Portuguese
3052
- - rm - Romansh
3053
- - ro - Romanian
3054
- - ru - Russian
3055
- - sc - Sardinian
3056
- - sk - Slovak
3057
- - sl - Slovenian
3058
- - sq - Albanian
3059
- - sr - Serbian
3060
- - st - Southern Sotho
3061
- - sv - Swedish
3062
- - sw - Swahili
3063
- - ta - Tamil
3064
- - te - Telugu
3065
- - th - Thai
3066
- - tl - Tagalog
3067
- - tr - Turkish
3068
- - tt - Tatar
3069
- - ug - Uyghur
3070
- - uk - Ukrainian
3071
- - ur - Urdu
3072
- - uz - Uzbek
3073
- - vi - Vietnamese
3074
- - wo - Wolof
3075
- - zh-CN - Chinese (Simplified)
3076
- - zh-HK - Chinese (Hong Kong)
3077
- - zh-TW - Chinese (Traditional)
3078
- - zh-YUE - Chinese (Yue)
3079
-
3080
- # Retries
3081
-
3082
- CMDx provides automatic retry functionality for tasks that encounter transient failures. This is essential for handling temporary issues like network timeouts, rate limits, or database locks without manual intervention.
3083
-
3084
- ## Basic Usage
3085
-
3086
- Configure retries upto n attempts without any delay.
3087
-
3088
- ```ruby
3089
- class FetchExternalData < CMDx::Task
3090
- settings retries: 3
3091
-
3092
- def work
3093
- response = HTTParty.get("https://api.example.com/data")
3094
- context.data = response.parsed_response
3095
- end
3096
- end
3097
- ```
3098
-
3099
- When an exception occurs during execution, CMDx automatically retries up to the configured limit.
3100
-
3101
- ## Selective Retries
3102
-
3103
- By default, CMDx retries on `StandardError` and its subclasses. Narrow this to specific exception types:
3104
-
3105
- ```ruby
3106
- class ProcessPayment < CMDx::Task
3107
- settings retries: 5, retry_on: [Stripe::RateLimitError, Net::ReadTimeout]
3108
-
3109
- def work
3110
- # Your logic here...
3111
- end
3112
- end
3113
- ```
3114
-
3115
- !!! warning "Important"
3116
-
3117
- Only exceptions matching the `retry_on` configuration will trigger retries. Uncaught exceptions immediately fail the task.
3118
-
3119
- ## Retry Jitter
3120
-
3121
- Add delays between retry attempts to avoid overwhelming external services or to implement exponential backoff strategies.
3122
-
3123
- ### Fixed Value
3124
-
3125
- Use a numeric value to calculate linear delay (`jitter * current_retry`):
3126
-
3127
- ```ruby
3128
- class ImportRecords < CMDx::Task
3129
- settings retries: 3, retry_jitter: 0.5
3130
-
3131
- def work
3132
- # Delays: 0s, 0.5s (retry 1), 1.0s (retry 2), 1.5s (retry 3)
3133
- context.records = ExternalAPI.fetch_records
3134
- end
3135
- end
3136
- ```
3137
-
3138
- ### Symbol References
3139
-
3140
- Define an instance method for custom delay logic:
3141
-
3142
- ```ruby
3143
- class SyncInventory < CMDx::Task
3144
- settings retries: 5, retry_jitter: :exponential_backoff
3145
-
3146
- def work
3147
- context.inventory = InventoryAPI.sync
3148
- end
3149
-
3150
- private
3151
-
3152
- def exponential_backoff(current_retry)
3153
- 2 ** current_retry # 2s, 4s, 8s, 16s, 32s
3154
- end
3155
- end
3156
- ```
3157
-
3158
- ### Proc or Lambda
3159
-
3160
- Pass a proc for inline delay calculations:
3161
-
3162
- ```ruby
3163
- class PollJobStatus < CMDx::Task
3164
- # Proc
3165
- settings retries: 10, retry_jitter: proc { |retry_count| [retry_count * 0.5, 5.0].min }
3166
-
3167
- # Lambda
3168
- settings retries: 10, retry_jitter: ->(retry_count) { [retry_count * 0.5, 5.0].min }
3169
-
3170
- def work
3171
- # Delays: 0.5s, 1.0s, 1.5s, 2.0s, 2.5s, 3.0s, 3.5s, 4.0s, 4.5s, 5.0s (capped)
3172
- context.status = JobAPI.check_status(context.job_id)
3173
- end
3174
- end
3175
- ```
3176
-
3177
- ### Class or Module
3178
-
3179
- Implement reusable delay logic in dedicated modules and classes:
3180
-
3181
- ```ruby
3182
- class ExponentialBackoff
3183
- def call(task, retry_count)
3184
- base_delay = task.context.base_delay || 1.0
3185
- [base_delay * (2 ** retry_count), 60.0].min
3186
- end
3187
- end
3188
-
3189
- class FetchUserProfile < CMDx::Task
3190
- # Class or Module
3191
- settings retries: 4, retry_jitter: ExponentialBackoff
3192
-
3193
- # Instance
3194
- settings retries: 4, retry_jitter: ExponentialBackoff.new
3195
-
3196
- def work
3197
- # Your logic here...
3198
- end
3199
- end
3200
- ```
3201
-
3202
- # Task Deprecation
3203
-
3204
- Manage legacy tasks gracefully with built-in deprecation support. Choose how to handle deprecated tasksβ€”log warnings for awareness, issue Ruby warnings for development, or prevent execution entirely.
3205
-
3206
- ## Modes
3207
-
3208
- ### Raise
3209
-
3210
- Prevent task execution completely. Perfect for tasks that must no longer run.
3211
-
3212
- !!! warning
3213
-
3214
- Use `:raise` mode carefullyβ€”it will break existing workflows immediately.
3215
-
3216
- ```ruby
3217
- class ProcessObsoleteAPI < CMDx::Task
3218
- settings(deprecated: :raise)
3219
-
3220
- def work
3221
- # Will never execute...
3222
- end
3223
- end
3224
-
3225
- result = ProcessObsoleteAPI.execute
3226
- #=> raises CMDx::DeprecationError: "ProcessObsoleteAPI usage prohibited"
3227
- ```
3228
-
3229
- ### Log
3230
-
3231
- Allow execution while tracking deprecation in logs. Ideal for gradual migrations.
3232
-
3233
- ```ruby
3234
- class ProcessLegacyFormat < CMDx::Task
3235
- settings(deprecated: :log)
3236
-
3237
- # Same
3238
- settings(deprecated: true)
3239
-
3240
- def work
3241
- # Executes but logs deprecation warning...
3242
- end
3243
- end
3244
-
3245
- result = ProcessLegacyFormat.execute
3246
- result.successful? #=> true
3247
-
3248
- # Deprecation warning appears in logs:
3249
- # WARN -- : DEPRECATED: ProcessLegacyFormat - migrate to replacement or discontinue use
3250
- ```
3251
-
3252
- ### Warn
3253
-
3254
- Issue Ruby warnings visible during development and testing. Keeps production logs clean while alerting developers.
3255
-
3256
- ```ruby
3257
- class ProcessOldData < CMDx::Task
3258
- settings(deprecated: :warn)
3259
-
3260
- def work
3261
- # Executes but emits Ruby warning...
3262
- end
3263
- end
3264
-
3265
- result = ProcessOldData.execute
3266
- result.successful? #=> true
3267
-
3268
- # Ruby warning appears in stderr:
3269
- # [ProcessOldData] DEPRECATED: migrate to a replacement or discontinue use
3270
- ```
3271
-
3272
- ## Declarations
3273
-
3274
- ### Symbol or String
3275
-
3276
- ```ruby
3277
- class OutdatedConnector < CMDx::Task
3278
- # Symbol
3279
- settings(deprecated: :raise)
3280
-
3281
- # String
3282
- settings(deprecated: "warn")
3283
- end
3284
- ```
3285
-
3286
- ### Boolean or Nil
3287
-
3288
- ```ruby
3289
- class OutdatedConnector < CMDx::Task
3290
- # Deprecates with default :log mode
3291
- settings(deprecated: true)
3292
-
3293
- # Skips deprecation
3294
- settings(deprecated: false)
3295
- settings(deprecated: nil)
3296
- end
3297
- ```
3298
-
3299
- ### Method
3300
-
3301
- ```ruby
3302
- class OutdatedConnector < CMDx::Task
3303
- # Symbol
3304
- settings(deprecated: :deprecated?)
3305
-
3306
- def work
3307
- # Your logic here...
3308
- end
3309
-
3310
- private
3311
-
3312
- def deprecated?
3313
- Time.now.year > 2024 ? :raise : false
3314
- end
3315
- end
3316
- ```
3317
-
3318
- ### Proc or Lambda
3319
-
3320
- ```ruby
3321
- class OutdatedConnector < CMDx::Task
3322
- # Proc
3323
- settings(deprecated: proc { Rails.env.development? ? :raise : :log })
3324
-
3325
- # Lambda
3326
- settings(deprecated: -> { Current.tenant.legacy_mode? ? :warn : :raise })
3327
- end
3328
- ```
3329
-
3330
- ### Class or Module
3331
-
3332
- ```ruby
3333
- class OutdatedTaskDeprecator
3334
- def call(task)
3335
- task.class.name.include?("Outdated")
3336
- end
3337
- end
3338
-
3339
- class OutdatedConnector < CMDx::Task
3340
- # Class or Module
3341
- settings(deprecated: OutdatedTaskDeprecator)
3342
-
3343
- # Instance
3344
- settings(deprecated: OutdatedTaskDeprecator.new)
3345
- end
3346
- ```
3347
-
3348
- # Workflows
3349
-
3350
- Compose multiple tasks into powerful, sequential pipelines. Workflows provide a declarative way to build complex business processes with conditional execution, shared context, and flexible error handling.
3351
-
3352
- ## Declarations
3353
-
3354
- Tasks run in declaration order (FIFO), sharing a common context across the pipeline.
3355
-
3356
- !!! warning
3357
-
3358
- Don't define a `work` method in workflowsβ€”the module handles execution automatically.
3359
-
3360
- ### Task
3361
-
3362
- ```ruby
3363
- class OnboardingWorkflow < CMDx::Task
3364
- include CMDx::Workflow
3365
-
3366
- task CreateUserProfile
3367
- task SetupAccountPreferences
3368
-
3369
- tasks SendWelcomeEmail, SendWelcomeSms, CreateDashboard
3370
- end
3371
- ```
3372
-
3373
- !!! tip
3374
-
3375
- Execute tasks in parallel via the [cmdx-parallel](https://github.com/drexed/cmdx-parallel) gem.
3376
-
3377
- ### Group
3378
-
3379
- Group related tasks to share configuration:
3380
-
3381
- !!! warning "Important"
3382
-
3383
- Settings and conditionals apply to all tasks in the group.
3384
-
3385
- ```ruby
3386
- class ContentModerationWorkflow < CMDx::Task
3387
- include CMDx::Workflow
3388
-
3389
- # Screening phase
3390
- tasks ScanForProfanity, CheckForSpam, ValidateImages, breakpoints: ["skipped"]
3391
-
3392
- # Review phase
3393
- tasks ApplyFilters, ScoreContent, FlagSuspicious
3394
-
3395
- # Decision phase
3396
- tasks PublishContent, QueueForReview, NotifyModerators
3397
- end
3398
- ```
3399
-
3400
- ### Conditionals
3401
-
3402
- Conditionals support multiple syntaxes for flexible execution control:
3403
-
3404
- ```ruby
3405
- class ContentAccessCheck
3406
- def call(task)
3407
- task.context.user.can?(:publish_content)
3408
- end
3409
- end
3410
-
3411
- class OnboardingWorkflow < CMDx::Task
3412
- include CMDx::Workflow
3413
-
3414
- # If and/or Unless
3415
- task SendWelcomeEmail, if: :email_configured?, unless: :email_disabled?
3416
-
3417
- # Proc
3418
- task SendWelcomeEmail, if: -> { Rails.env.production? && self.class.name.include?("Premium") }
3419
-
3420
- # Lambda
3421
- task SendWelcomeEmail, if: proc { context.features_enabled? }
3422
-
3423
- # Class or Module
3424
- task SendWelcomeEmail, unless: ContentAccessCheck
3425
-
3426
- # Instance
3427
- task SendWelcomeEmail, if: ContentAccessCheck.new
3428
-
3429
- # Conditional applies to all tasks of this declaration group
3430
- tasks SendWelcomeEmail, CreateDashboard, SetupTutorial, if: :email_configured?
3431
-
3432
- private
3433
-
3434
- def email_configured?
3435
- context.user.email_address == true
3436
- end
3437
-
3438
- def email_disabled?
3439
- context.user.communication_preference == :disabled
3440
- end
3441
- end
3442
- ```
3443
-
3444
- ## Halt Behavior
3445
-
3446
- By default, skipped tasks don't stop the workflowβ€”they're treated as no-ops. Configure breakpoints globally or per-task to customize this behavior.
3447
-
3448
- ```ruby
3449
- class AnalyticsWorkflow < CMDx::Task
3450
- include CMDx::Workflow
3451
-
3452
- task CollectMetrics # If fails β†’ workflow stops
3453
- task FilterOutliers # If skipped β†’ workflow continues
3454
- task GenerateDashboard # Only runs if no failures occurred
3455
- end
3456
- ```
3457
-
3458
- ### Task Configuration
3459
-
3460
- Configure halt behavior for the entire workflow:
3461
-
3462
- ```ruby
3463
- class SecurityWorkflow < CMDx::Task
3464
- include CMDx::Workflow
3465
-
3466
- # Halt on both failed and skipped results
3467
- settings(workflow_breakpoints: ["skipped", "failed"])
3468
-
3469
- task PerformSecurityScan
3470
- task ValidateSecurityRules
3471
- end
3472
-
3473
- class OptionalTasksWorkflow < CMDx::Task
3474
- include CMDx::Workflow
3475
-
3476
- # Never halt, always continue
3477
- settings(breakpoints: [])
3478
-
3479
- task TryBackupData
3480
- task TryCleanupLogs
3481
- task TryOptimizeCache
3482
- end
3483
- ```
3484
-
3485
- ### Group Configuration
3486
-
3487
- Different task groups can have different halt behavior:
3488
-
3489
- ```ruby
3490
- class SubscriptionWorkflow < CMDx::Task
3491
- include CMDx::Workflow
3492
-
3493
- task CreateSubscription, ValidatePayment, workflow_breakpoints: ["skipped", "failed"]
3494
-
3495
- # Never halt, always continue
3496
- task SendConfirmationEmail, UpdateBilling, breakpoints: []
3497
- end
3498
- ```
3499
-
3500
- ## Nested Workflows
3501
-
3502
- Build hierarchical workflows by composing workflows within workflows:
3503
-
3504
- ```ruby
3505
- class EmailPreparationWorkflow < CMDx::Task
3506
- include CMDx::Workflow
3507
-
3508
- task ValidateRecipients
3509
- task CompileTemplate
3510
- end
3511
-
3512
- class EmailDeliveryWorkflow < CMDx::Task
3513
- include CMDx::Workflow
3514
-
3515
- tasks SendEmails, TrackDeliveries
3516
- end
3517
-
3518
- class CompleteEmailWorkflow < CMDx::Task
3519
- include CMDx::Workflow
3520
-
3521
- task EmailPreparationWorkflow
3522
- task EmailDeliveryWorkflow, if: proc { context.preparation_successful? }
3523
- task GenerateDeliveryReport
3524
- end
3525
- ```
3526
-
3527
- ## Parallel Execution
3528
-
3529
- Run tasks concurrently using the [Parallel](https://github.com/grosser/parallel) gem. It automatically uses all available processors for maximum throughput.
3530
-
3531
- !!! warning
3532
-
3533
- Context is read-only during parallel execution. Load all required data beforehand.
3534
-
3535
- ```ruby
3536
- class SendWelcomeNotifications < CMDx::Task
3537
- include CMDx::Workflow
3538
-
3539
- # Default options (dynamically calculated to available processors)
3540
- tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel
3541
-
3542
- # Fix number of threads
3543
- tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel, in_threads: 2
3544
-
3545
- # Fix number of forked processes
3546
- tasks SendWelcomeEmail, SendWelcomeSms, SendWelcomePush, strategy: :parallel, in_processes: 2
3547
-
3548
- # NOTE: Reactors are not supported
3549
- end
3550
- ```
3551
-
3552
- ## Task Generator
3553
-
3554
- Generate new CMDx workflow tasks quickly using the built-in generator:
3555
-
3556
- ```bash
3557
- rails generate cmdx:workflow SendNotifications
3558
- ```
3559
-
3560
- This creates a new workflow task file with the basic structure:
3561
-
3562
- ```ruby
3563
- # app/tasks/send_notifications.rb
3564
- class SendNotifications < CMDx::Task
3565
- include CMDx::Workflow
3566
-
3567
- tasks Task1, Task2
3568
- end
3569
- ```
3570
-
3571
- !!! tip
3572
-
3573
- Use **present tense verbs + pluralized noun** for workflow task names, eg: `SendNotifications`, `DownloadFiles`, `ValidateDocuments`
3574
-
3575
- # Tips and Tricks
3576
-
3577
- Best practices, patterns, and techniques to build maintainable CMDx applications.
3578
-
3579
- ## Project Organization
3580
-
3581
- ### Directory Structure
3582
-
3583
- Create a well-organized command structure for maintainable applications:
3584
-
3585
- ```text
3586
- /app/
3587
- └── /tasks/
3588
- β”œβ”€β”€ /invoices/
3589
- β”‚ β”œβ”€β”€ calculate_tax.rb
3590
- β”‚ β”œβ”€β”€ validate_invoice.rb
3591
- β”‚ β”œβ”€β”€ send_invoice.rb
3592
- β”‚ └── process_invoice.rb # workflow
3593
- β”œβ”€β”€ /reports/
3594
- β”‚ β”œβ”€β”€ generate_pdf.rb
3595
- β”‚ β”œβ”€β”€ compile_data.rb
3596
- β”‚ β”œβ”€β”€ export_csv.rb
3597
- β”‚ └── create_reports.rb # workflow
3598
- β”œβ”€β”€ application_task.rb # base class
3599
- β”œβ”€β”€ authenticate_session.rb
3600
- └── activate_account.rb
3601
- ```
3602
-
3603
- ### Naming Conventions
3604
-
3605
- Follow consistent naming patterns for clarity and maintainability:
3606
-
3607
- ```ruby
3608
- # Verb + Noun
3609
- class ExportData < CMDx::Task; end
3610
- class CompressFile < CMDx::Task; end
3611
- class ValidateSchema < CMDx::Task; end
3612
-
3613
- # Use present tense verbs for actions
3614
- class GenerateToken < CMDx::Task; end # βœ“ Good
3615
- class GeneratingToken < CMDx::Task; end # ❌ Avoid
3616
- class TokenGeneration < CMDx::Task; end # ❌ Avoid
3617
- ```
3618
-
3619
- ### Story Telling
3620
-
3621
- Break down complex logic into descriptive methods that read like a narrative:
3622
-
3623
- ```ruby
3624
- class ProcessOrder < CMDx::Task
3625
- def work
3626
- charge_payment_method
3627
- assign_to_warehouse
3628
- send_notification
3629
- end
3630
-
3631
- private
3632
-
3633
- def charge_payment_method
3634
- order.primary_payment_method.charge!
3635
- end
3636
-
3637
- def assign_to_warehouse
3638
- order.ready_for_shipping!
3639
- end
3640
-
3641
- def send_notification
3642
- if order.products_out_of_stock?
3643
- OrderMailer.pending(order).deliver
3644
- else
3645
- OrderMailer.preparing(order).deliver
3646
- end
3647
- end
3648
- end
3649
- ```
3650
-
3651
- ### Style Guide
3652
-
3653
- Follow this order for consistent, readable tasks:
3654
-
3655
- ```ruby
3656
- class ExportReport < CMDx::Task
3657
-
3658
- # 1. Register functions
3659
- register :middleware, CMDx::Middlewares::Correlate
3660
- register :validator, :format, FormatValidator
3661
-
3662
- # 2. Define callbacks
3663
- before_execution :find_report
3664
- on_complete :track_export_metrics, if: ->(task) { Current.tenant.analytics? }
3665
-
3666
- # 3. Declare attributes
3667
- attributes :user_id
3668
- required :report_id
3669
- optional :format_type
3670
-
3671
- # 4. Define work method
3672
- def work
3673
- report.compile!
3674
- report.export!
3675
-
3676
- context.exported_at = Time.now
3677
- end
3678
-
3679
- # TIP: Favor private business logic to reduce the surface of the public API.
3680
- private
3681
-
3682
- # 5. Build helper functions
3683
- def find_report
3684
- @report ||= Report.find(report_id)
3685
- end
3686
-
3687
- def track_export_metrics
3688
- Analytics.increment(:report_exported)
3689
- end
3690
-
3691
- end
3692
- ```
3693
-
3694
- ## Attribute Options
3695
-
3696
- Use `with_options` to reduce duplication:
3697
-
3698
- ```ruby
3699
- class ConfigureCompany < CMDx::Task
3700
- # Apply common options to multiple attributes
3701
- with_options(type: :string, presence: true) do
3702
- attributes :website, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
3703
- required :company_name, :industry
3704
- optional :description, format: { with: /\A[\w\s\-\.,!?]+\z/ }
3705
- end
3706
-
3707
- # Nested attributes with shared prefix
3708
- required :headquarters do
3709
- with_options(prefix: :hq_) do
3710
- attributes :street, :city, :zip_code, type: :string
3711
- required :country, type: :string, inclusion: { in: VALID_COUNTRIES }
3712
- optional :region, type: :string
3713
- end
3714
- end
3715
-
3716
- def work
3717
- # Your logic here...
3718
- end
3719
- end
3720
- ```
3721
-
3722
- ## More Examples
3723
-
3724
- - [Active Record Query Tagging](https://github.com/drexed/cmdx/blob/main/examples/active_record_query_tagging.md)
3725
- - [Paper Trail Whatdunnit](https://github.com/drexed/cmdx/blob/main/examples/paper_trail_whatdunnit.md)
3726
- - [Stoplight Circuit Breaker](https://github.com/drexed/cmdx/blob/main/examples/stoplight_circuit_breaker.md)
3727
-