Ruby Reactor vs dry-transaction vs Trailblazer: Choosing a Ruby Workflow Library in 2026
DEV Community

Ruby Reactor vs dry-transaction vs Trailblazer: Choosing a Ruby Workflow Library in 2026

The 30-Second Decision Matrix

If you only have 30 seconds, start here:

You want... Pick...
A simple pipeline - 3-5 steps, top-to-bottom, no parallelism dry-transaction
Railway-oriented programming with success/failure tracks, already in the Trailblazer ecosystem Trailblazer
A full saga orchestrator - DAG dependency resolution, Sidekiq async, auto-compensation, locks, dashboard Ruby Reactor
One-off fire-and-forget jobs, no coordination needed Raw Sidekiq

None of these are "better" than the others. They solve different problems. Let's walk through each one.

Meet the Libraries

dry-transaction (v0.16.0)

dry-transaction is a thin, focused gem from the dry-rb ecosystem. It wraps a series of operations in a sequential pipeline with a clean step DSL:

class CreateUser < Dry::Transaction
  step :validate
  step :persist
  step :send_welcome_email

  def validate(input) = # ...
  def persist(input) = # ...
  def send_welcome_email(input) = # ...
end

Steps run top-to-bottom. If any step returns a Failure, the pipeline stops immediately - no further steps execute. It's a railway under the hood: each step can produce Success(value) or Failure(error), and the pipeline routes accordingly.

What it's great at: Simple, synchronous pipelines. It's the Ruby equivalent of an Either monad chained with bind - clean, predictable, and minimal. If you're already in the dry-rb ecosystem (dry-validation, dry-types, dry-monads), it fits naturally.

What it doesn't do: No DAG or parallelism - steps are strictly sequential. No async execution. No built-in compensation (undo on failure). No coordination primitives (locks, rate limits, semaphores). No web dashboard. It's a pipeline composer, not an orchestrator.

Ideal for: User registration, form processing, data transformation pipelines - anything where "do A, then B, then C" is the whole story.

Trailblazer (activity-dsl-linear v1.2.6)

Trailblazer's activity DSL gives you railway-oriented programming with explicit success and failure tracks:

class Memo::Update < Trailblazer::Activity::Railway
  step :find_model
  step :validate, Output(:failure) => End(:validation_error)
  step :save
  fail :log_error

  def find_model(ctx, id:, **)
    ctx[:model] = Memo.find_by(id: id)
  end

  def validate(ctx, params:, **)
    return true if params[:body].is_a?(String) && params[:body].size > 10
    ctx[:errors] = "body not long enough"
    false
  end

  def save(ctx, model:, params:, **)
    model.update_attributes(params)
  end

  def log_error(ctx, **)
    ctx[:log] = "Something went wrong"
  end
end

You define steps and explicitly wire where success and failure lead. This gives you fine-grained control over branching - validation failure can go to a different endpoint than a save failure. Activities can be nested and composed. The developer gem adds tracing for debugging.

What it's great at: Complex branching logic. If your workflow has multiple failure modes that need different handling, Trailblazer's explicit wiring shines. It's also the natural choice if you're already using Trailblazer's Operation, Contract, and Representer layers - the activity DSL is the execution engine underneath.

What it doesn't do: No built-in DAG or automatic parallelism. Compensation is manual ( fail steps can do cleanup, but you write the logic). No built-in async via Sidekiq (there are Trailblazer-compatible solutions, but they're not in the core). No coordination primitives. No web dashboard in the open-source version. The Pro version has a visual workflow editor for long-running processes, but that's a paid tier.

Ideal for: Apps already in the Trailblazer ecosystem. Complex branching with multiple failure paths. Operations that need explicit success/failure routing.

Ruby Reactor (v0.5.2)

Ruby Reactor is a DAG-based saga orchestrator built on top of Sidekiq and Redis. It's the newest library in this comparison (first released 2025) and the most feature-dense:

class CheckoutReactor < RubyReactor::Reactor
  input :order_id

  with_lock(ttl: 60) { |inputs| "checkout:#{inputs[:order_id]}" }
  with_rate_limit(limits: { second: 100 }) { "stripe" }

  step :reserve_inventory do
    async true
    argument :order_id, input(:order_id)

    run do |args|
      reservation = Inventory.reserve(args[:order_id])
      Success(inventory_id: reservation.id)
    end

    undo do |_err, args|
      Inventory.release(args[:order_id])
      Success()
    end
  end

  step :charge_card do
    async true
    argument :order_id, input(:order_id)
    wait_for :reserve_inventory

    run do |args|
      charge = Payment.charge(args[:order_id])
      Success(payment_id: charge.id, amount: charge.amount)
    end

    undo do |_err, args|
      Payment.refund(args[:order_id])
      Success()
    end
  end

  step :create_label do
    async true
    wait_for :charge_card
    argument :order_id, input(:order_id)
    argument :payment, result(:charge_card)   # ← access previous step's result

    run do |args|
      label = Shipping.create_label(args[:order_id], args[:payment][:payment_id])
      Success(tracking_number: label.tracking_number)
    end

    undo do |_err, args|
      Shipping.cancel(args[:order_id])
      Success()
    end
  end

  step :send_confirmation do
    async true
    wait_for :create_label
    argument :order_id, input(:order_id)
    argument :tracking, result(:create_label) # ← pipeline results flow forward

    run do |args|
      Email.send_confirmation(args[:order_id], args[:tracking][:tracking_number])
      Success()
    end
  end

  step :track_analytics do
    async true

    run do |args|
      Analytics.track(args[:order_id])
      Success()
    end
  end

  returns :send_confirmation
end

Success(value) carries structured data through the pipeline. argument :payment, result(:charge_card) picks up the previous step's output - args[:payment] is { payment_id: "…", amount: 9900 }. This isn't just plumbing: every step's inputs, outputs, and status are persisted and visible in the web dashboard (a step-by-step trace of what ran, what returned what, and what failed) and captured by the OpenTelemetry middleware as span attributes (step.arguments.*, reactor.inputs.*) - making your workflow fully debuggable without grepping logs.

The DAG is built from wait_for and argument declarations. Same-level independent steps execute sequentially (step-level parallelism is on the roadmap), while the map step fans out over collections via Sidekiq workers. Steps marked async true dispatch to Sidekiq for background execution. If any step fails, undo blocks run automatically in reverse order.

What it's great at: Saga-pattern workflows where reliability matters. E-commerce checkouts, payment processing, data import pipelines, anything that touches money or external APIs. It's the only library that gives you DAG planning, automatic compensation, Sidekiq-native async, coordination primitives (locks, semaphores, rate limits, periods, ordered locks), a built-in web dashboard, OpenTelemetry tracing, and test_reactor for testing - all from the same DSL.

What it doesn't do: It's not a pipeline composer for simple operations - if your workflow is 3 synchronous steps with no parallelism, dry-transaction is a better fit. It has Sidekiq and Redis as hard dependencies. It's the youngest library (v0.5.2, active development) - the API is stable but less battle-tested than dry-transaction or Trailblazer which have years of production use.

Ideal for: Multi-step business transactions with dependency resolution, async execution, and coordination needs. Any workflow where "step 3 failed, now undo steps 1 and 2" is a real requirement, not a theoretical one.

Raw Sidekiq Jobs

Not a library, but the baseline worth comparing against. Raw Sidekiq gives you workers, queues, retries, and scheduling - and nothing else:

class CheckoutWorker
  include Sidekiq::Worker

  def perform(order_id)
    # You build everything: locks, rate limits, rollback, parallelism
  end
end

What it's great at: Fire-and-forget jobs. Full control. No abstraction overhead. You already have it if you use Sidekiq.

What it doesn't do: Everything beyond pushing a job and retrying it - you build from scratch.

Ideal for: Simple background jobs. The baseline to compare against when deciding if you need any library at all.

Feature Comparison

Here's the full matrix. Checkmarks mean "built-in, works out of the box." "Limited" means theoretically possible but requires significant manual work or community extensions. "Manual" means you build it yourself.

Feature Ruby Reactor dry-transaction Trailblazer Raw Sidekiq
Step DSL (declarative) βœ… βœ… βœ… -
DAG dependency resolution βœ… ❌ ❌ Manual
Async via Sidekiq βœ… ❌ Limited βœ…
Auto compensation/undo βœ… ❌ Manual Manual
Interrupts (pause/resume) βœ… ❌ Pro onlyΒΉ Manual
Distributed locks βœ… ❌ ❌ Manual
Semaphores βœ… ❌ ❌ Manual
Rate limiting βœ… ❌ ❌ Manual
Periods (dedup) βœ… ❌ ❌ Manual
Ordered locks (nonce) βœ… ❌ ❌ Manual
Map/batch parallelism βœ… ❌ ❌ Manual
Web dashboard βœ… ❌ Pro onlyΒ² ❌
OpenTelemetry tracing βœ… ❌ Dev onlyΒ³ Manual
Middleware pipeline βœ… ❌ ❌ Manual
Input validation βœ…β΄ βœ… βœ… Manual
Testing helpers βœ… βœ… βœ… Manual
Gem version 0.5.2 0.16.0 1.2.6 -
Hard dependencies sidekiq, redis dry-monads trailblazer-activity sidekiq, redis

ΒΉ Trailblazer Pro has a workflow engine for long-running processes with pause/resume semantics
Β² Trailblazer Pro includes a visual workflow editor and diagram viewer
Β³ Trailblazer's developer gem provides tracing for debugging, not production distributed tracing
⁴ Integrated with dry-validation for input schemas

Code Comparison: Same Checkout, Four Ways

Let's implement the same checkout workflow - reserve inventory, charge card, create shipping label, send confirmation - in all four approaches. The goal is to see what each library asks of you vs what it handles for you.

dry-transaction (29 lines)

class CheckoutTransaction < Dry::Transaction
  step :reserve_inventory
  step :charge_card
  step :create_label
  step :send_confirmation

  def reserve_inventory(input)
    InventoryService.reserve(input[:order])
    Success(input)
  rescue => e
    # Manual rollback: nothing to undo yet
    Failure("Inventory reservation failed: #{e.message}")
  end

  def charge_card(input)
    PaymentGateway.charge(input[:order])
    Success(input)
  rescue => e
    # Manual rollback: release inventory
    InventoryService.release(input[:order])
    Failure("Payment failed: #{e.message}")
  end

  def create_label(input)
    ShippingService.create_label(input[:order])
    Success(input)
  rescue => e
    # Manual rollback: refund card, release inventory
    PaymentGateway.refund(input[:order])
    InventoryService.release(input[:order])
    Failure("Shipping failed: #{e.message}")
  end

  def send_confirmation(input)
    EmailService.send_confirmation(input[:order])
    Success(input)
  end
end

What you write: Every step, every rescue, every compensation block - by hand. Sequential only. No lock (double-processing possible). No rate limit (Stripe will throttle you).

Trailblazer (35 lines)

class CheckoutOperation < Trailblazer::Activity::Railway
  step :reserve_inventory
  step :charge_card
  step :create_label
  step :send_confirmation
  fail :undo_inventory, magnetic_to: nil, Output(:success) => Track(:failure)

  def reserve_inventory(ctx, order:, **)
    ctx[:inventory] = InventoryService.reserve(order)
  end

  def charge_card(ctx, order:, **)
    ctx[:charge] = PaymentGateway.charge(order)
  rescue => e
    ctx[:error] = "Payment failed: #{e.message}"
    false
  end

  def create_label(ctx, order:, charge:, **)
    ctx[:label] = ShippingService.create_label(order, charge)
  rescue => e
    ctx[:error] = "Shipping failed: #{e.message}"
    # Manual compensation
    PaymentGateway.refund(ctx[:charge])
    false
  end

  def send_confirmation(ctx, order:, charge:, label:, **)
    EmailService.send_confirmation(order, charge, label)
  end

  def undo_inventory(ctx, inventory:, **)
    InventoryService.release(inventory[:order])
  end
end

What you write: Success/failure tracks, manual compensation wiring, explicit context passing. More control over branching, but more code. Sequential only. No lock, no rate limit.

Ruby Reactor (45 lines)

class CheckoutReactor < RubyReactor::Reactor
  input :order_id

  with_lock(ttl: 60) { |inputs| "checkout:#{inputs[:order_id]}" }
  with_rate_limit(limits: { second: 100 }) { "stripe" }

  step :reserve_inventory do
    async true
    argument :order_id, input(:order_id)

    run do |args|
      reservation = Inventory.reserve(args[:order_id])
      Success(inventory_id: reservation.id)
    end

    undo do |_err, args|
      Inventory.release(args[:order_id])
      Success()
    end
  end

  step :charge_card do
    async true
    argument :order_id, input(:order_id)
    wait_for :reserve_inventory

    run do |args|
      charge = Payment.charge(args[:order_id])
      Success(payment_id: charge.id, amount: charge.amount)
    end

    undo do |_err, args|
      Payment.refund(args[:order_id])
      Success()
    end
  end

  step :create_label do
    async true
    wait_for :charge_card
    argument :order_id, input(:order_id)
    argument :payment, result(:charge_card)   # ← access previous step's result

    run do |args|
      label = Shipping.create_label(args[:order_id], args[:payment][:payment_id])
      Success(tracking_number: label.tracking_number)
    end

    undo do |_err, args|
      Shipping.cancel(args[:order_id])
      Success()
    end
  end

  step :send_confirmation do
    async true
    wait_for :create_label
    argument :order_id, input(:order_id)
    argument :tracking, re

Comments

No comments yet. Start the discussion.