Dry::Workflow 💎⚙️🔁
Dry::Workflow
is a Ruby gem for defining complex, multi-step business processes with a clear, dry-transaction
-like DSL. Its key feature is robust, explicit support for rollback operations for each step, ensuring data consistency and providing a structured way to handle failures.
If a step in your workflow fails, Dry::Workflow
will automatically attempt to run the defined rollback
operations for all previously completed steps, in reverse order.
✨ Features
-
Declarative DSL: Define workflow steps clearly using
step
,map
, andtry
. -
Explicit Rollbacks: Define a
rollback
operation for any step that might have side effects. - Automatic Rollback Execution: On failure, previously successful steps with rollbacks are automatically undone in reverse order.
-
dry-monads
Integration: LeveragesDry::Monads::Result
(Success
andFailure
) for clear success/failure paths. - Flexible Operations: Step operations and rollbacks can be instance methods or procs.
-
Error Handling:
try
steps allow for graceful exception handling within the workflow.
🤷♂️ Why Dry::Workflow?
While dry-transaction
is excellent for composing operations, managing complex rollback scenarios across multiple steps can become cumbersome. Dry::Workflow
aims to make this explicit and easier to manage, especially for operations involving database transactions, external API calls, or other side effects that need to be undone if the overall process fails.
Think of it for processes like:
- Create User record (rollback: delete User record)
- Charge credit card via API (rollback: refund credit card via API)
- Send welcome email (rollback: potentially send "ignore previous email" or log, if critical)
If step 2 fails, step 1's rollback is automatically called.
🚀 Installation
Add this line to your application's Gemfile:
gem 'dry-workflow' # TODO: Ensure this is the correct name once published
And then execute:
$ bundle install
Or install it yourself as:
$ gem install dry-workflow # TODO: Ensure this is the correct name once published
🛠️ Usage
Defining a Workflow
Create a class and include Dry::Workflow
:
require 'dry/workflow'
require 'dry/monads/all' # For Success/Failure
class CreateUserWorkflow
include Dry::Workflow
include Dry::Monads[:result] # For Success and Failure
# Define your steps
step :validate_params, rollback: :log_validation_rollback # Rollbacks are optional
step :create_user_record, rollback: :delete_user_record
try :charge_credit_card, with: :attempt_charge, rollback: :refund_charge, catch_exception: SomeApi::Error
map :prepare_output
# Implement the operations for each step
private
def validate_params(input)
puts "Validating: #{input.inspect}"
if input[:email] && input[:name]
Success(input.merge(validated: true))
else
Failure(errors: "Email and name are required")
end
end
def log_validation_rollback(validated_input)
# validated_input here is the *output* of the validate_params step
puts "Rolling back validation (logging only) for: #{validated_input.inspect}"
# No actual state change to revert here, just an example
end
def create_user_record(params)
puts "Creating user: #{params[:name]}"
# Simulate DB creation
user_id = rand(1000)
# The output of this step (Success value) will be passed to its rollback
Success(params.merge(user_id: user_id))
end
def delete_user_record(created_user_data)
# created_user_data here is the *output* of the create_user_record step
puts "Rolling back user creation: Deleting user ID #{created_user_data[:user_id]}"
# Simulate DB deletion
end
def attempt_charge(user_data)
puts "Attempting to charge card for user ID: #{user_data[:user_id]}"
# Simulate API call
# raise SomeApi::Error, "Card declined" # To test failure and rollback
if user_data[:simulate_charge_failure]
# This will be caught by `try` and wrapped in Failure
raise StandardError, "Simulated card declined by API"
end
transaction_id = "txn_#{rand(1_000_000)}"
puts "Charge successful: #{transaction_id}"
Success(user_data.merge(transaction_id: transaction_id))
end
def refund_charge(charge_data)
# charge_data here is the *output* of the attempt_charge step
puts "Rolling back charge: Refunding transaction ID #{charge_data[:transaction_id]}"
# Simulate API refund call
end
def prepare_output(charged_data)
puts "Preparing output for: #{charged_data.inspect}"
{
message: "User #{charged_data[:name]} created and charged successfully!",
user_id: charged_data[:user_id],
transaction_id: charged_data[:transaction_id]
}
# Map steps don't need to return Success/Failure, their output is automatically wrapped.
end
end
Executing the Workflow
Instantiate your workflow class and call it with an initial input:
workflow = CreateUserWorkflow.new
input = { name: "Jane Doe", email: "jane@example.com" }
result = workflow.call(input)
if result.success?
puts "✅ Workflow Succeeded!"
puts "Output: #{result.value!}"
else
puts "❌ Workflow Failed!"
puts "Error: #{result.failure}"
end
puts "\n--- Simulating a failure during charge ---"
failing_input = { name: "John Doe", email: "john@example.com", simulate_charge_failure: true }
failing_result = workflow.call(failing_input)
if failing_result.success?
puts "✅ Workflow Succeeded (unexpected)!"
puts "Output: #{failing_result.value!}"
else
puts "❌ Workflow Failed (as expected)!"
puts "Error: #{failing_result.failure}"
end
DSL Methods
-
step(name, with: nil, rollback: nil, **options)
:-
name
: Symbol, the name of the step. Ifwith
is not provided, an instance method with this name is called. -
with
: Optional. Symbol (method name) or Proc to be executed for this step. -
rollback
: Optional. Symbol (method name) or Proc to be executed if this step (or a subsequent step) fails. The rollback operation receives the successful output of its corresponding step as input. - The operation (from
name
orwith
) must returnSuccess(value)
orFailure(value)
.
-
-
try(name, with: nil, rollback: nil, catch_exception: StandardError, **options)
:- Similar to
step
, but wraps the operation in aTry
monad. -
catch_exception
: Optional. The specific exception class (or array of classes) to catch. Defaults toStandardError
. - If the specified exception is caught, the step results in a
Failure(exception_object)
. - The operation itself does not need to return
Success
/Failure
if it's expected to succeed or raise the caught exception.
- Similar to
-
map(name, with: nil, **options)
:- Similar to
step
, but the operation is expected to always succeed and transform the input. - The return value of the operation is automatically wrapped in
Success(value)
. - Rollbacks are generally not defined for
map
steps as they are not expected to have side effects that need undoing.
- Similar to
Rollback Mechanism
- When a step returns
Failure(reason)
or atry
step catches an exception:- The workflow halts immediately.
- It iterates through all previously completed steps in reverse order.
- If a completed step has a
rollback
operation defined, that operation is called. -
Important: The
rollback
operation receives theSuccess
value (the output) of its corresponding original step as its argument. This allows the rollback to know what exactly to undo.
- Rollback operations should ideally not fail. If a rollback operation itself raises an unhandled exception, it will be logged to
$stderr
, but the workflow will attempt to continue rolling back other steps. - The final result of the
call
method will be theFailure
object from the step that initially failed.
🚂 Rails Integration Example (Service Objects)
Dry::Workflow
is well-suited for creating service objects in a Ruby on Rails application. Here's how you might integrate it into a controller for a "Create Post" feature.
1. Define the Workflow (Service Object)
Let's assume you have a Post
model.
# app/services/posts/create_workflow.rb
require 'dry/workflow'
require 'dry/monads/all'
module Posts
class CreateWorkflow
include Dry::Workflow
include Dry::Monads[:result, :do] # :do notation can be nice for chaining
# Hypothetical external service for content moderation
class ModerationService
class ModerationFailedError < StandardError; end
def self.request_moderation(title, content)
puts "MODERATION: Requesting for '#{title}'"
# Simulate API call
# raise ModerationFailedError, "Content flagged as inappropriate" if content.include?("forbidden")
if content.include?("forbidden")
# This will be caught by the `try` step in the workflow
raise ModerationFailedError, "Content flagged as inappropriate by API"
end
"moderation_id_#{rand(1000)}"
end
def self.retract_moderation_request(moderation_id)
puts "MODERATION: Retracting request for ID '#{moderation_id}'"
# Simulate API call to retract
end
end
# Define steps
step :validate_params
step :create_post_record, rollback: :delete_post_record
try :request_content_moderation,
with: :moderate_content,
rollback: :retract_moderation,
catch_exception: ModerationService::ModerationFailedError
map :prepare_success_response
private
# In a real app, you might use a validation library like dry-validation
def validate_params(params)
errors = []
errors << "Title can't be blank" if params[:title].to_s.strip.empty?
errors << "Content can't be blank" if params[:content].to_s.strip.empty?
if errors.empty?
Success(params.slice(:title, :content, :author_id).merge(validated: true))
else
Failure(type: :validation, errors: errors)
end
end
def create_post_record(validated_params)
puts "DB: Creating post titled '#{validated_params[:title]}'"
# In a real app:
# post = Post.new(title: validated_params[:title], content: validated_params[:content], user_id: validated_params[:author_id])
# if post.save
# Success(validated_params.merge(post_id: post.id, post_object: post))
# else
# Failure(type: :database, errors: post.errors.full_messages)
# end
# Simulated:
post_id = rand(10000)
puts "DB: Post ##{post_id} created."
Success(validated_params.merge(post_id: post_id))
end
def delete_post_record(created_post_data)
# created_post_data is the output of create_post_record
puts "DB: Rolling back post creation. Deleting Post ID ##{created_post_data[:post_id]}"
# In a real app:
# Post.find_by(id: created_post_data[:post_id])&.destroy
end
def moderate_content(post_data)
# post_data is the output of create_post_record
puts "SERVICE: Requesting content moderation for Post ID ##{post_data[:post_id]}"
moderation_id = ModerationService.request_moderation(post_data[:title], post_data[:content])
Success(post_data.merge(moderation_id: moderation_id))
end
def retract_moderation(moderation_data)
# moderation_data is the output of moderate_content
puts "SERVICE: Rolling back content moderation. Retracting Moderation ID ##{moderation_data[:moderation_id]}"
ModerationService.retract_moderation_request(moderation_data[:moderation_id])
end
def prepare_success_response(moderated_data)
{
message: "Post '#{moderated_data[:title]}' created and submitted for moderation.",
post_id: moderated_data[:post_id],
moderation_id: moderated_data[:moderation_id]
}
end
end
end
2. Use in a Rails Controller
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
# before_action :authenticate_user! # Assuming Devise or similar
def new
@post = Post.new # For form_with
end
def create
# In a real app, ensure current_user is available
# author_id = current_user.id
author_id = 1 # Placeholder for example
workflow_input = post_params.to_h.symbolize_keys.merge(author_id: author_id)
workflow = Posts::CreateWorkflow.new
result = workflow.call(workflow_input)
if result.success?
# Access the successful output via result.value!
flash[:notice] = result.value![:message]
redirect_to post_path(result.value![:post_id]) # Assuming you have a post show path
else
# Handle failure
failure_data = result.failure
@post = Post.new(post_params) # Re-populate form
if failure_data[:type] == :validation
flash.now[:alert] = "Validation failed: #{failure_data[:errors].join(', ')}"
elsif failure_data.is_a?(Posts::CreateWorkflow::ModerationService::ModerationFailedError)
flash.now[:alert] = "Content moderation failed: #{failure_data.message}"
else
flash.now[:alert] = "Could not create post: #{failure_data.inspect}"
end
render :new, status: :unprocessable_entity
end
end
private
def post_params
params.require(:post).permit(:title, :content)
end
end
This example demonstrates:
- Passing controller params to the workflow.
- Handling
Success
andFailure
outcomes in the controller. - A workflow with steps that simulate database interaction and external API calls, along with their respective rollbacks.
- Different types of failure objects that can be inspected in the controller.
Remember to place your workflow classes in a suitable directory, like app/services
or app/workflows
.
🤝 Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/your-username/dry-workflow. This project is intended to be a safe, welcoming space for collaboration.
- Fork it (
https://github.com/your-username/dry-workflow/fork
) - Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request
📜 License
The gem is available as open source under the terms of the MIT License.