0.0
The project is in a healthy, maintained state
Dry::Workflow allows developers to define complex, multi-step business processes with a clear DSL. It supports `step`, `map`, and `try` operations, similar to dry-transaction, but with an added emphasis on defining and executing rollback procedures for each step if the workflow fails. This helps ensure data consistency and provides a structured way to handle failures in long-running or critical operations.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Development

~> 2.0
~> 13.0
~> 3.0
~> 1.0

Runtime

 Project Readme

Dry::Workflow 💎⚙️🔁

Gem Version Build Status

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, and try.
  • 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: Leverages Dry::Monads::Result (Success and Failure) 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:

  1. Create User record (rollback: delete User record)
  2. Charge credit card via API (rollback: refund credit card via API)
  3. 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. If with 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 or with) must return Success(value) or Failure(value).
  • try(name, with: nil, rollback: nil, catch_exception: StandardError, **options):

    • Similar to step, but wraps the operation in a Try monad.
    • catch_exception: Optional. The specific exception class (or array of classes) to catch. Defaults to StandardError.
    • 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.
  • 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.

Rollback Mechanism

  1. When a step returns Failure(reason) or a try 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 the Success value (the output) of its corresponding original step as its argument. This allows the rollback to know what exactly to undo.
  2. 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.
  3. The final result of the call method will be the Failure 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 and Failure 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.

  1. Fork it (https://github.com/your-username/dry-workflow/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

📜 License

The gem is available as open source under the terms of the MIT License.