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.