Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Building with Ruby

This guide covers building Tasker step handlers with Ruby using the tasker-rb gem in a Rails application.

Quick Start

Add the gem to your Gemfile:

gem 'tasker-rb'

Generate a step handler with tasker-ctl:

tasker-ctl template generate step_handler \
  --language ruby \
  --param name=ValidateCart \
  --param module_name=Ecommerce

This creates a DSL-style handler with typed inputs that delegates to a service method.

Writing a Handler (DSL)

Every handler follows the three-layer pattern: type definition, handler declaration, service delegation.

# app/services/types.rb — the contract
module Types
  module Ecommerce
    class OrderInput < Types::InputStruct
      attribute :cart_items, Types::Array.optional
      attribute :customer_email, Types::String.optional
      attribute :payment_info, Types::Hash.optional
      attribute :shipping_address, Types::Hash.optional
    end
  end
end

# app/handlers/ecommerce/step_handlers/validate_cart_handler.rb — the handler
module Ecommerce
  module StepHandlers
    extend TaskerCore::StepHandler::Functional

    ValidateCartHandler = step_handler(
      'Ecommerce::StepHandlers::ValidateCartHandler',
      inputs: Types::Ecommerce::OrderInput
    ) do |inputs:, context:|
      Ecommerce::Service.validate_cart_items(
        cart_items: inputs.cart_items,
        customer_email: inputs.customer_email,
      )
    end
  end
end

The step_handler method registers a handler and takes a block. The inputs: keyword argument receives a Dry::Struct instance with typed, optional attributes. The block body is a single service call.

Type System

Ruby handlers use Dry::Struct for both input and result types.

Input types extend Types::InputStruct — a base class where all attributes are optional and omittable, so missing keys don’t raise:

module Types
  module Ecommerce
    class OrderInput < Types::InputStruct
      attribute :cart_items, Types::Array.optional
      attribute :customer_email, Types::String.optional
      attribute :payment_info, Types::Hash.optional
      attribute :shipping_address, Types::Hash.optional
    end
  end
end

Result types extend Types::ResultStruct — similar to InputStruct but describing what a handler returns (used by downstream depends_on):

module Types
  module Ecommerce
    class ValidateCartResult < Types::ResultStruct
      attribute :validated_items, Types::Array
      attribute :item_count, Types::Integer
      attribute :subtotal, Types::Float
      attribute :tax, Types::Float
      attribute :total, Types::Float
    end
  end
end

Both InputStruct and ResultStruct support string and symbol key access (e.g., result['user_id'] and result[:user_id]) and nested access via dig.

Accessing Task Context

The inputs: config extracts the full task context into a typed Dry::Struct instance. Fields are matched by name from the submitted JSON:

ValidateCartHandler = step_handler(
  'Ecommerce::StepHandlers::ValidateCartHandler',
  inputs: Types::Ecommerce::OrderInput
) do |inputs:, context:|
  # inputs.cart_items, inputs.customer_email, etc. are typed attributes
  Ecommerce::Service.validate_cart_items(cart_items: inputs.cart_items)
end

The context: keyword provides execution metadata (task UUID, step UUID, step config) but most handlers don’t need it directly.

Working with Dependencies

The depends_on: config injects typed results from upstream steps. Each entry maps a keyword argument name to a ['step_name', ResultModel] pair:

ProcessPaymentHandler = step_handler(
  'Ecommerce::StepHandlers::ProcessPaymentHandler',
  depends_on: { cart_total: ['validate_cart', Types::Ecommerce::ValidateCartResult] },
  inputs: Types::Ecommerce::OrderInput
) do |cart_total:, inputs:, context:|
  Ecommerce::Service.process_payment(
    payment_info: inputs.payment_info,
    total: cart_total&.total,
  )
end

Handlers can reference any ancestor step in the DAG — not just direct predecessors. Here’s a convergence handler that accesses three upstream steps:

CreateOrderHandler = step_handler(
  'Ecommerce::StepHandlers::CreateOrderHandler',
  depends_on: {
    cart_validation: ['validate_cart', Types::Ecommerce::ValidateCartResult],
    payment_result: ['process_payment', Types::Ecommerce::ProcessPaymentResult],
    inventory_result: ['update_inventory', Types::Ecommerce::UpdateInventoryResult],
  },
  inputs: Types::Ecommerce::OrderInput
) do |cart_validation:, payment_result:, inventory_result:, inputs:, context:|
  Ecommerce::Service.create_order(
    cart_validation: cart_validation,
    payment_result: payment_result,
    inventory_result: inventory_result,
    customer_email: inputs.customer_email,
    shipping_address: inputs.shipping_address,
  )
end

Multi-Step Example: Data Pipeline

The data pipeline workflow demonstrates a parallel DAG — three independent extract branches, each feeding its own transform, converging at aggregation:

extract_sales    extract_inventory    extract_customers
     │                  │                    │
     ▼                  ▼                    ▼
transform_sales  transform_inventory  transform_customers
     │                  │                    │
     └──────────────────┼────────────────────┘
                        ▼
               aggregate_metrics
                        │
                        ▼
              generate_insights

Ruby handlers follow the same concise pattern:

module DataPipeline
  module StepHandlers
    extend TaskerCore::StepHandler::Functional

    # Extract — no dependencies, runs in parallel
    ExtractSalesDataHandler = step_handler(
      'DataPipeline::StepHandlers::ExtractSalesDataHandler',
      inputs: Types::DataPipeline::PipelineInput
    ) do |inputs:, context:|
      DataPipeline::Service.extract_sales_data(
        source: inputs.source,
        date_range_start: inputs.date_range_start,
      )
    end

    # Transform — depends on one extract branch
    TransformSalesHandler = step_handler(
      'DataPipeline::StepHandlers::TransformSalesHandler',
      depends_on: { sales_data: ['extract_sales_data', Types::DataPipeline::ExtractSalesResult] }
    ) do |sales_data:, context:|
      DataPipeline::Service.transform_sales(sales_data: sales_data)
    end

    # Aggregate — converges three transform branches
    AggregateMetricsHandler = step_handler(
      'DataPipeline::StepHandlers::AggregateMetricsHandler',
      depends_on: {
        sales: ['transform_sales', Types::DataPipeline::TransformSalesResult],
        inventory: ['transform_inventory', Types::DataPipeline::TransformInventoryResult],
        customers: ['transform_customers', Types::DataPipeline::TransformCustomersResult],
      }
    ) do |sales:, inventory:, customers:, context:|
      DataPipeline::Service.aggregate_metrics(
        sales: sales, inventory: inventory, customers: customers,
      )
    end
  end
end

Error Handling

Raise typed exceptions to control retry behavior:

# Permanent error — will NOT be retried
raise TaskerCore::Errors::PermanentError.new(
  'Payment declined: insufficient funds',
  error_code: 'PAYMENT_DECLINED'
)

# Retryable error — will be retried per the step's retry config
raise TaskerCore::Errors::RetryableError.new(
  'Payment gateway temporarily unavailable'
)

Error codes (like PAYMENT_DECLINED, EMPTY_CART) are included in the step result for observability and debugging.

Testing

DSL handlers are constants holding callable blocks — test by invoking the service functions directly or by using RSpec mocks:

RSpec.describe 'Ecommerce::StepHandlers::ValidateCartHandler' do
  let(:inputs) do
    Types::Ecommerce::OrderInput.new(
      cart_items: [{ 'sku' => 'SKU-001', 'name' => 'Widget', 'quantity' => 2, 'unit_price' => 29.99 }],
      customer_email: 'test@example.com'
    )
  end

  it 'delegates to the service' do
    expect(Ecommerce::Service).to receive(:validate_cart_items)
      .with(cart_items: inputs.cart_items, customer_email: inputs.customer_email)
      .and_return({ validated_items: [], total: 64.79 })

    context = instance_double(TaskerCore::Types::StepContext)
    result = Ecommerce::StepHandlers::ValidateCartHandler.call(inputs: inputs, context: context)

    expect(result[:total]).to eq(64.79)
  end
end

For handlers with dependencies, construct result models directly:

let(:cart) { Types::Ecommerce::ValidateCartResult.new(total: 64.79, validated_items: []) }
let(:payment) { Types::Ecommerce::ProcessPaymentResult.new(payment_id: 'pay_abc') }
let(:inventory) { Types::Ecommerce::UpdateInventoryResult.new(inventory_log_id: 'log_123') }
let(:inputs) { Types::Ecommerce::OrderInput.new(customer_email: 'test@example.com') }

it 'creates order from upstream data' do
  expect(Ecommerce::Service).to receive(:create_order)
    .and_return({ order_id: 'ORD-001' })

  context = instance_double(TaskerCore::Types::StepContext)
  result = Ecommerce::StepHandlers::CreateOrderHandler.call(
    cart_validation: cart, payment_result: payment,
    inventory_result: inventory, inputs: inputs, context: context,
  )

  expect(result[:order_id]).to eq('ORD-001')
end

Because handlers delegate to service methods, you can also test the services directly without any Tasker infrastructure.

Handler Variants

API Handler

Adds HTTP client methods with built-in error classification. Uses TaskerCore::StepHandler::Mixins::API with the class-based pattern. See Class-Based Handlers — API Handler.

Decision Handler

Adds workflow routing with decision_success() for activating downstream step sets. Uses TaskerCore::StepHandler::Mixins::Decision with the class-based pattern. See Conditional Workflows.

Batchable Handler

Adds batch processing with Analyzer/Worker pattern using TaskerCore::StepHandler::Mixins::Batchable. See Class-Based Handlers — Batchable Handler and Batch Processing.

Class-Based Alternative

If you prefer class inheritance, all handler types support a class-based pattern where you inherit from TaskerCore::StepHandler::Base and implement call(context). See Class-Based Handlers for the full reference.

Next Steps