DEV Community

Cover image for Ruby Reactor vs dry-transaction vs Trailblazer: Choosing a Ruby Workflow Library in 2026
Artur Pañach
Artur Pañach

Posted on

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

Four ways to orchestrate business logic in Ruby. One map to find yours.

You're building something that involves multiple steps. Charge a card, send an email, update inventory. Simple.

Then someone says "What if step 3 fails? What undoes steps 1 and 2?" and suddenly you're evaluating workflow libraries.

There are four mainstream approaches in Ruby today — Ruby Reactor, dry-transaction, Trailblazer, and raw Sidekiq jobs. This guide helps you pick the right one — not by ranking them, but by mapping them to the problem you're actually solving.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, 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
Enter fullscreen mode Exit fullscreen mode

What you write: Step definitions with their inputs, dependencies (wait_for / argument), execution logic (run), compensation logic (undo), and coordination rules (with_lock, with_rate_limit). Each Success() carries structured data through the pipeline — result(:charge_card) surfaces { payment_id: …, amount: … } in subsequent steps. Every step's inputs, outputs, and status are persisted and visible in the web dashboard and captured as OpenTelemetry span attributes — making the workflow fully debuggable without logging. Ruby Reactor handles DAG-based ordering, automatic compensation, distributed lock, rate limiting, and async dispatch through Sidekiq. For parallel batch processing of collections, the map step distributes work across Sidekiq workers.

Raw Sidekiq (82 lines)

class CheckoutWorker
  include Sidekiq::Worker
  def perform(order_id)
    @order_id = order_id
    @lock_key = "checkout:#{order_id}"
    unless Redis.current.set(@lock_key, "1", nx: true, ex: 60)
      self.class.perform_in(5.seconds, order_id); return
    end
    begin
      StripeRateLimiter.check!(:charge, key: "store:#{order.store_id}")
      InventoryService.reserve(order)
    rescue => e; Redis.current.del(@lock_key); raise; end
    begin
      charge = PaymentGateway.charge(order)
    rescue => e
      InventoryService.release(order)
      Redis.current.del(@lock_key); raise
    end
    begin
      label = ShippingService.create_label(order, charge)
    rescue => e
      PaymentGateway.refund(charge)
      InventoryService.release(order)
      Redis.current.del(@lock_key); raise
    end
    EmailService.send_confirmation(order, charge, label)
    AnalyticsService.track(order)
    Redis.current.del(@lock_key)
  end
  def order; @order ||= Order.find(@order_id); end
end
Enter fullscreen mode Exit fullscreen mode

What you write: Everything. The lock, the rate limit check, the compensation blocks in reverse order, the lock cleanup on every exit path. 82 lines, and you still don't have DAG dependency resolution or a test helper.


The Axes That Matter

Forget "best." Ask yourself two questions:

1. How complex is your workflow?

Simple pipeline (A→B→C)     →  dry-transaction
Branching (if X→A else→B)   →  Trailblazer
DAG dependency resolution     →  Ruby Reactor
Fire-and-forget             →  Raw Sidekiq
Enter fullscreen mode Exit fullscreen mode

2. How much coordination do you need?

None (no locks, no rollback)          →  Raw Sidekiq or dry-transaction
Manual (I can write it, but ugh)      →  Trailblazer
Automatic (built-in, declarative)     →  Ruby Reactor
Enter fullscreen mode Exit fullscreen mode

If you're on the left side of both axes, dry-transaction or raw Sidekiq is the right call. If you're on the right side — DAG complexity, async execution, automatic coordination — Ruby Reactor is the only library that gives you all of that without building it yourself. For collection-level parallelism (processing thousands of records), the map step distributes work across Sidekiq workers.


Testing: The Hidden Differentiator

One thing that doesn't show up in the feature table: testing. Here's how each library tests the checkout workflow:

dry-transaction

# Unit-test each step, integration-test the pipeline
result = CheckoutTransaction.new.call(order: order)
expect(result).to be_success
# Cannot test: lock behavior, rate limiting, compensation (it's inline)
Enter fullscreen mode Exit fullscreen mode

Trailblazer

# Call the activity, inspect ctx
signal, (ctx, _) = CheckoutOperation.([{ order: order }, {}])
expect(signal.success?).to be true
expect(ctx[:label]).to be_present
# Cannot test: async behavior, locks, rate limits
Enter fullscreen mode Exit fullscreen mode

Ruby Reactor

# Success path — inspect every step's return value
subject = test_reactor(CheckoutReactor, order_id: 42)

expect(subject).to be_success
expect(subject).to have_run_step(:charge_card).after(:reserve_inventory)

# step_result exposes what Success() returned from any step
expect(subject.step_result(:charge_card)).to include(
  payment_id: kind_of(String),
  amount: 9900
)
expect(subject.step_result(:create_label)).to include(
  tracking_number: kind_of(String)
)

# Failure path — mock a step to simulate errors
subject = test_reactor(CheckoutReactor, order_id: 42)
  .mock_step(:charge_card) { Failure("Card declined") }

expect(subject).to be_failure
expect(subject.error).to include("Card declined")
expect(subject).to have_run_step(:reserve_inventory)   # ran before failure
expect(subject).not_to have_run_step(:create_label)    # never reached

# Coordination primitives are testable:
expect("checkout:42").to be_locked
expect("stripe").to have_rate_limit_count(1).for(:second)
Enter fullscreen mode Exit fullscreen mode

test_reactor runs synchronously and auto-processes Sidekiq jobs. step_result(:charge_card) returns the exact value passed to Success() — the same data your dashboard would show and OpenTelemetry would capture as span attributes. Dedicated matchers cover locks, rate limits, semaphores, periods, and ordered locks — behaviors you can't unit-test in any other library.


Observability: Dashboard + OpenTelemetry — What the Others Don't Show You

One dimension where the gap widens dramatically: visibility into what actually happened. Here's what you get with Ruby Reactor vs the alternatives:

Capability Ruby Reactor dry-transaction Trailblazer Raw Sidekiq
Web dashboard (execution trace) Pro only
Step inputs/outputs persisted ctx only Manual
step_result(:name) in tests ctx inspection Manual
OpenTelemetry distributed tracing Dev only Manual
Trace across async boundaries Manual
Sensitive value redaction Manual

The web dashboard shows every reactor execution — which steps ran, what Success() returned, what Failure() carried, and the full compensation chain — all from the data flowing through result(:step_name). No more Rails.logger.info in every step.

OpenTelemetry turns the same data into distributed traces. The built-in middleware captures step arguments and results as span attributes, propagates context across async Sidekiq boundaries, and redacts sensitive inputs (input :password, redact: true). You get a single connected trace from reactor start to final step across multiple Sidekiq workers — zero config.

Together they turn "what happened in that checkout?" from a grep-and-guess exercise into a click-and-see one.


Where Each Library Fits

Library Sweet spot Best avoided when...
dry-transaction Simple pipelines, dry-rb stack, synchronous-only You need parallelism, async, or coordination
Trailblazer Complex branching, Trailblazer ecosystem, railway pattern You want a focused library, not a framework
Ruby Reactor Multi-step sagas, DAG dependency resolution, async, coordination primitives Your workflow is 3 synchronous steps (use dry-transaction)
Raw Sidekiq Fire-and-forget jobs, full control, no abstraction You're writing locks, rollback, or coordination by hand

Migration Paths

Already using one library and considering another? Here's the practical path:

  • dry-transaction → Ruby Reactor: Replace step definitions with Ruby Reactor's DSL. Add async: true where needed. Move compensation from rescue blocks to undo blocks. The mental model is similar — both are step-based DSLs.
  • Trailblazer → Ruby Reactor: Extract the execution logic from Operation classes into reactor steps. Replace fail track cleanup with undo blocks. The coordination primitives fill the gap Trailblazer's Pro features cover.
  • Raw Sidekiq → Ruby Reactor: Find the job with the most rescue blocks. Rewrite as a reactor. Run both in parallel for a week. Delete the old worker. Start with one, expand from there.

The Bottom Line

There's no universal "best" Ruby workflow library. There's the right one for your problem:

  • dry-transaction is the best pipeline composer — minimal, focused, reliable.
  • Trailblazer is the best railway framework — explicit routing, rich ecosystem.
  • Ruby Reactor is the best saga orchestrator — DAG dependency resolution, async execution, automatic compensation, coordination primitives, testing, and observability in one DSL.
  • Raw Sidekiq is the best when you don't need any of the above.

The trap isn't picking the wrong library — it's picking a library that's too simple for your problem and ending up building the missing features yourself. If you're writing manual rollback blocks, Redis locks, and rate limiters inside your Sidekiq jobs, you've already outgrown raw Sidekiq. The question isn't whether to use a coordinator — it's which one handles the complexity you're already managing by hand.

gem 'dry-transaction'     # for pipelines
gem 'trailblazer-activity-dsl-linear'  # for railway operations
gem 'ruby_reactor'        # for sagas with teeth
Enter fullscreen mode Exit fullscreen mode

CTA: What's your current workflow setup? dry-transaction? Trailblazer? Raw Sidekiq? Something I missed? What would make you switch — or what's keeping you on your current stack? 👇

Top comments (0)