Optional Steps Guide¶
Optional steps allow you to build dynamic pipelines where the execution path is determined at runtime. Unlike regular steps that always execute (if their dependencies are met), optional steps only run when explicitly activated.
Overview¶
Optional steps are declared with depends_on: :optional and are activated using result.activate(:step_name). This enables powerful patterns like:
- Router Pattern - Route to different handlers based on data type
- Soft Failure Pattern - Graceful error handling with cleanup
- Feature Flags - Enable/disable functionality at runtime
- Chained Activation - Optional steps that activate other optional steps
Declaring Optional Steps¶
Use depends_on: :optional to mark a step as optional:
pipeline = SimpleFlow::Pipeline.new do
step :start, ->(r) { r.continue(r.value) }, depends_on: :none
# This step will only run if explicitly activated
step :optional_processor, ->(r) {
r.continue(process(r.value))
}, depends_on: :optional
end
Activating Optional Steps¶
Use result.activate(:step_name) to add steps to the execution plan:
# Activate a single step
result.activate(:optional_processor)
# Activate multiple steps at once
result.activate(:step_a, :step_b, :step_c)
# Chain activations
result
.activate(:step_a)
.activate(:step_b)
Pattern 1: Router Pattern¶
The router pattern uses a decision step to route execution to different optional handlers based on the data being processed.
Use Case: Document Processing¶
Process different document types with specialized handlers:
pipeline = SimpleFlow::Pipeline.new do
# Router step - decides which processor to activate
step :analyze_document, ->(result) {
doc = result.value
case doc[:type]
when :pdf
result.continue(doc).activate(:process_pdf)
when :image
result.continue(doc).activate(:process_image)
when :spreadsheet
result.continue(doc).activate(:process_spreadsheet)
else
result.continue(doc).activate(:process_generic)
end
}, depends_on: :none
# Optional processors - only the activated one runs
step :process_pdf, ->(result) {
doc = result.value
extracted = extract_pdf_text(doc)
result.continue(doc.merge(text: extracted, processor: :pdf))
}, depends_on: :optional
step :process_image, ->(result) {
doc = result.value
extracted = run_ocr(doc)
result.continue(doc.merge(text: extracted, processor: :image))
}, depends_on: :optional
step :process_spreadsheet, ->(result) {
doc = result.value
data = parse_cells(doc)
result.continue(doc.merge(data: data, processor: :spreadsheet))
}, depends_on: :optional
step :process_generic, ->(result) {
doc = result.value
result.continue(doc.merge(processor: :generic))
}, depends_on: :optional
end
# Usage
pdf_result = pipeline.call_parallel(
SimpleFlow::Result.new({ type: :pdf, content: "..." })
)
# Only :analyze_document and :process_pdf execute
image_result = pipeline.call_parallel(
SimpleFlow::Result.new({ type: :image, content: "..." })
)
# Only :analyze_document and :process_image execute
Benefits¶
- Clean Separation - Each handler is isolated
- Easy Extension - Add new types without modifying existing code
- Clear Intent - The routing logic is explicit
- Testability - Each handler can be tested independently
Pattern 2: Soft Failure Pattern¶
Instead of immediately halting on errors, activate error handling and cleanup steps. This allows for graceful degradation, proper logging, and resource cleanup.
Use Case: Order Processing with Recovery¶
pipeline = SimpleFlow::Pipeline.new do
step :validate_order, ->(result) {
order = result.value
if order[:items].empty?
# Instead of halt, activate error handlers
result
.with_error(:validation, "Order has no items")
.continue(order.merge(failed_at: :validate_order))
.activate(:handle_error, :cleanup)
else
result.continue(order.merge(validated: true))
end
}, depends_on: :none
step :process_payment, ->(result) {
order = result.value
return result if order[:failed_at] # Skip if already failed
if payment_declined?(order)
result
.with_error(:payment, "Payment declined")
.continue(order.merge(failed_at: :process_payment))
.activate(:handle_error, :cleanup)
else
result.continue(order.merge(paid: true))
end
}, depends_on: [:validate_order]
step :fulfill_order, ->(result) {
order = result.value
return result if order[:failed_at]
result.continue(order.merge(fulfilled: true))
}, depends_on: [:process_payment]
# Error handling step - logs, alerts, etc.
step :handle_error, ->(result) {
order = result.value
log_error(order[:failed_at], result.errors)
send_alert(order)
result
.with_context(:error_logged, true)
.continue(order.merge(error_handled: true))
}, depends_on: :optional
# Cleanup step - releases resources, then halts
step :cleanup, ->(result) {
order = result.value
refund_payment(order) if order[:paid]
release_inventory(order) if order[:inventory_reserved]
result
.continue(order.merge(cleaned_up: true, status: :failed))
.halt # Now we halt after cleanup
}, depends_on: :optional
end
Benefits¶
- Graceful Degradation - Proper cleanup before stopping
- Auditability - All errors are logged before halting
- Resource Safety - Reservations are released, payments refunded
- Flexibility - Different error types can trigger different handlers
Pattern 3: Chained Optional Activation¶
Optional steps can activate other optional steps, creating dynamic chains:
pipeline = SimpleFlow::Pipeline.new do
step :check_eligibility, ->(result) {
user = result.value
if user[:tier] == :gold && user[:years] >= 2
result.continue(user).activate(:upgrade_to_platinum)
elsif user[:tier] == :silver && user[:years] >= 1
result.continue(user).activate(:upgrade_to_gold)
else
result.continue(user.merge(upgrade: :none))
end
}, depends_on: :none
step :upgrade_to_gold, ->(result) {
user = result.value
new_user = user.merge(tier: :gold, benefits: [:priority_support])
# Gold upgrade also triggers loyalty bonus
result.continue(new_user).activate(:apply_loyalty_bonus)
}, depends_on: :optional
step :upgrade_to_platinum, ->(result) {
user = result.value
new_user = user.merge(tier: :platinum, benefits: [:concierge, :events])
# Platinum triggers BOTH loyalty bonus AND special gift
result.continue(new_user).activate(:apply_loyalty_bonus, :send_special_gift)
}, depends_on: :optional
step :apply_loyalty_bonus, ->(result) {
user = result.value
bonus = user[:years] * 1000
result.continue(user.merge(bonus_points: bonus))
}, depends_on: :optional
step :send_special_gift, ->(result) {
user = result.value
schedule_gift_delivery(user)
result.continue(user.merge(gift_scheduled: true))
}, depends_on: :optional
end
Pattern 4: Feature Flags¶
Use optional steps to enable/disable features at runtime:
pipeline = SimpleFlow::Pipeline.new do
step :start, ->(result) {
config = result.value[:config]
activations = []
activations << :enhanced_logging if config[:enhanced_logging]
activations << :analytics if config[:analytics_enabled]
activations << :rate_limiting if config[:rate_limiting]
result.continue(result.value).activate(*activations)
}, depends_on: :none
step :process, ->(r) { r.continue(process(r.value)) }, depends_on: [:start]
step :enhanced_logging, ->(result) {
log_detailed(result)
result.continue(result.value)
}, depends_on: :optional
step :analytics, ->(result) {
track_event(result)
result.continue(result.value)
}, depends_on: :optional
step :rate_limiting, ->(result) {
check_rate_limit(result)
result.continue(result.value)
}, depends_on: :optional
end
Execution Order¶
Optional steps are injected into the DAG when activated:
- Pipeline starts with only non-optional steps in the execution plan
- When a step calls
activate(:optional_step), the step is added to the plan - The activated step runs when all its dependencies are satisfied
- If an optional step depends on another optional step, both must be activated
# This step won't run unless BOTH :a and :b are activated
step :c, ->(r) { ... }, depends_on: [:a, :b]
# where :a and :b are optional
Error Handling¶
Unknown Step¶
Activating an unknown step raises ArgumentError:
Error message: Step :start attempted to activate unknown step :nonexistent
Non-Optional Step¶
Activating a non-optional step raises ArgumentError:
step :regular, ->(r) { r.continue(r.value) }, depends_on: :none
step :start, ->(result) {
result.activate(:regular) # Raises ArgumentError
}, depends_on: :none
Error message: Step :start attempted to activate non-optional step :regular. Only steps declared with depends_on: :optional can be activated.
Idempotent Activation¶
Activating the same step multiple times is safe:
result
.activate(:step_a)
.activate(:step_a) # No-op, step_a already activated
.activate(:step_a) # Still no-op
Best Practices¶
1. Optional Steps as Terminal Paths¶
Optional steps work best when they are complete processing paths:
# Good - each optional step is a complete path
step :process_pdf, ->(r) {
extract_text(r.value)
store_document(r.value)
r.continue(r.value.merge(processed: true))
}, depends_on: :optional
# Avoid - splitting optional processing across multiple steps
# (harder to reason about execution order)
2. Use the Skip Pattern for Non-Optional Steps¶
When optional steps might not run, non-optional steps should handle missing data:
step :process_data, ->(result) {
# Skip if we're in error state (from soft failure pattern)
return result if result.value[:failed_at]
# Normal processing
result.continue(process(result.value))
}, depends_on: [:validate]
3. Activate Early, Act Later¶
Activate steps as early as possible so the DAG can be properly built:
# Good - activate immediately based on data
step :router, ->(result) {
result.continue(result.value).activate(:handler)
}, depends_on: :none
# Avoid - activating after complex processing
# (makes execution flow harder to follow)
4. Document Activation Requirements¶
Make it clear which steps can activate which optional steps:
# Only :router can activate these handlers
step :process_pdf, ->(r) { ... }, depends_on: :optional
step :process_image, ->(r) { ... }, depends_on: :optional
# Only :upgrade steps can activate these bonuses
step :apply_loyalty_bonus, ->(r) { ... }, depends_on: :optional
step :send_special_gift, ->(r) { ... }, depends_on: :optional
Testing Optional Steps¶
Test Activation Logic¶
def test_router_activates_pdf_processor
pipeline = build_document_pipeline
result = SimpleFlow::Result.new({ type: :pdf })
output = pipeline.call_parallel(result)
assert_equal :pdf, output.value[:processor]
end
def test_router_activates_image_processor
pipeline = build_document_pipeline
result = SimpleFlow::Result.new({ type: :image })
output = pipeline.call_parallel(result)
assert_equal :image, output.value[:processor]
end
Test Error Handling¶
def test_soft_failure_activates_cleanup
pipeline = build_order_pipeline
result = SimpleFlow::Result.new({ items: [] }) # Invalid order
output = pipeline.call_parallel(result)
refute output.continue?
assert output.value[:cleaned_up]
assert output.value[:error_handled]
end
Test Chained Activation¶
def test_gold_upgrade_activates_loyalty_bonus
pipeline = build_upgrade_pipeline
result = SimpleFlow::Result.new({ tier: :silver, years: 2 })
output = pipeline.call_parallel(result)
assert_equal :gold, output.value[:tier]
assert output.value[:bonus_points]
end
Related Documentation¶
- Steps - Step types and contracts
- Result API - The
activatemethod - Pipeline API - The
optional_stepsattribute - Error Handling - Error handling patterns
- Example 13 - Complete code examples