How to use transactions to unclutter your Rails controllers
Tags: rails, beginners, tutorial
Published: 2019-09-13 11:59:26

When your Rails app needs to handle multiple steps, your controllers' methods can become a mess.

Don't despair, though. You can delegate sequential steps to business transactions and Marie-Kondo those messy controllers. I'll show you how.

Let's keep in mind that business transactions are different from ActiveRecord transactions. Business transactions allow you to create a series of steps, each resulting in a `Success` or a `Failure` object. ActiveRecord transactions are about ensuring that several database operations work as a single unit and are all rollbacked if any error occurs.

In this tutorial, I'll use the [dry-transaction gem](https://github.com/dry-rb/dry-transaction/) whose documentation is neat.

## From clean to messy controllers in no time

Let's start with coding a basic controller.

```ruby
  class LeadsController < ApplicationController
    def new
      @lead = Lead.new
    end

    def create
      lead = Lead.create(lead_params)

      if lead.errors.any?
        lead = Lead.new(lead_params)
        render :new
      else
        redirect_to lead_path(lead)
      end
    end

    private

    def lead_params
      params.require(:lead).permit(
        :first_name, :last_name, :email
      )
    end
  end
```

Okay, so here's a basic `LeadsController`. You have two actions: `new` and `create`. As you can see, the `create` method is straightforward:
- I create a new lead with the information filled by our lead.
- I handle any errors by rendering the `leads/new` form prefilled with the information our current lead already gave us.
- If everything goes well, I redirect our lead to her profile page (or any page of my choosing).

But what if I want to do more? Let's say I want to:
- connect to a distant CRM [through an API](https://remimercier.com/what-is-an-api/) and synchronize the lead's information with my sales team
- send a welcome text and an email to the lead
- notify someone in my team

```ruby
  class LeadsController < ApplicationController
    def new
      @lead = Lead.new
    end

    def create
      lead = Lead.create(lead_params)

      if lead.errors.any?
        lead = Lead.new(lead_params)
        render :new
      else
        # synchronize new lead to distant CRM
        MyDistantCrm.create_lead(lead)

        # send welcome sms and email to new lead
        MySmsProvider.welcome_sms(lead).deliver_now
        LeadMailer.welcome(lead).deliver_now

        # notify business developer
        BusinessDevelopperMailer.new_lead(lead).deliver_now

        # redirect new leads to their profile page
        redirect_to lead_path(lead)

      # rescue any error to avoid a 500 error
      rescue StandardError => exception
        flash[:error] = exception
      end
    end

    private

    def lead_params
      params.require(:lead).permit(
        :first_name, :last_name, :email
      )
    end
  end
```

As you can see, there are several new steps doing very different things. In a real-life app, we could do a lot more than that: create associations, generate SKU numbers, etc. The other thing is that all these steps are dependent on the `lead` being created without any errors. This puts a lot of stuff into an `else` branch. This is where business transactions can come in handy.

Let's see how to do it.

## Transactions to the rescue

Let's set up dry-transaction and see how we can unclutter our controller.

### Step 1: Install dry-transaction

Add this line to your application's Gemfile:

```zsh
  gem 'dry-transaction'
```

Then execute:

```zsh
  bundle install
```

### Step 2: General principles

Before we dive into moving parts of our `LeadsController#create` into a transaction, let's look at a transaction file to see what's what.

<blockquote>A business transaction is a series of operations where any can fail and stop the processing.</blockquote>

Each step is processed one at a time and must return either a `Success` or a `Failure` object.

```ruby
  class Leads::Create < BaseTransaction
    # Here, I define the operation sequence
    tee :params
    step :create_lead

    # Here, I define each operation
    def params(input)
      @params = input.fetch(:params)
    end

    def create_lead(input)
      @lead = Lead.create(@params)

      if @lead.errors.any?
        Failure(error: @lead.errors.full_messages.join(' | '))
      else
        Success(input)
      end
    end
  end
```

Here are a few points to keep in mind:
- `tee` is a specific kind of step. It'll always return a `Success`. This is fine here because I'm simply fetching an input.
- `step` is the basic operation. It'll have to return either a `Success` or a `Failure` object.
- `@params` will be available to all other steps or methods of my transaction when `lead` will not.

### Step 3: Move the controller's logic into the transaction

Now, we can move parts of our `LeadsController#create` into a transaction.

My app's architecture was:

```zsh
  - app
    - controllers
      - application_controller.rb
      - leads_controller.rb
```

At the end of this tutorial, it'll be:

```zsh
  - app
    - controllers
      - application_controller.rb
      - leads_controller.rb
    - transactions
      - base_transaction.rb
      - leads
        - create.rb
```

Let's build `base_transaction.rb` first.

```ruby
  class BaseTransaction
    include Dry::Transaction

    def self.call(*args, &block)
      new.call(*args, &block)
    end
  end
```

`def self.call(*args, &block)` will allow us to **call** the transaction from our controllers with a hash of arguments.

I'll start with the transaction we started earlier and I'll move parts of our `LeadsController#create` into it.

```ruby
  class Leads::Create < BaseTransaction
    tee :params
    step :create_lead
    step :create_distant_lead
    step :send_welcome_sms
    step :send_welcome_email
    step :notify_business_developper

    def params(input)
      @params = input.fetch(:params)
    end

    def create_lead(input)
      @lead = Lead.create(@params)

      if @lead.errors.any?
        Failure(error: @lead.errors.full_messages.join(' | '))
      else
        Success(input)
      end
    end

    def create_distant_lead(input)
      MyDistantCrm.create_lead(@lead)

      Success(input)
    rescue StandardError => exception
      Failure(error: exception)
    end

    def send_welcome_sms(input)
      MySmsProvider.welcome_sms(@lead).deliver_now

      Success(input)
    rescue StandardError => exception
      Failure(error: exception)
    end

    def send_welcome_email(input)
      LeadMailer.welcome(@lead).deliver_now

      Success(input)
    rescue StandardError => exception
      Failure(error: exception)
    end

    def notify_business_developper(input)
      BusinessDevelopperMailer.new_lead(@lead).deliver_now

      Success(input)
    rescue StandardError => exception
      Failure(error: exception)
    end
  end
```

All of my `LeadsController#create` steps are now in my transaction.

Each operation handles its own errors and return a `Success` or a `Failure` object.

For instance, if my `MySmsProvider.welcome_sms(@lead).deliver_now` returns an error, my transaction will not execute the next steps and will return a `Failure` so I know that something went wrong here.

### Step 4: Call the transaction and handle its results

Now that all my steps are in my transaction, what should I do with my controller? We'll start by calling the transaction.

```ruby
  class LeadsController < ApplicationController
    def new
      @lead = Lead.new
    end

    def create
      Leads::Create.call(params: lead_params)
    end

    private

    def lead_params
      params.require(:lead).permit(
        :first_name, :last_name, :email
      )
    end
  end
```

Neat right?

<blockquote>Calling a transaction will run its operations in their specified order, with the output of each operation becoming the input for the next.</blockquote>

As I said before, a transaction either returns a `Success` or a `Failure` object. I can handle these results in the controller.

In our original controller, I would render the `new` form if the lead creation failed. On the other hand, if the creation succeeded, I'd redirect my new lead to its profile. Let's do this now!

```ruby
  class LeadsController < ApplicationController
    def new
      @lead = Lead.new
    end

    def create
      Leads::Create.call(lead_params) do |m|
        m.success do
          redirect_to lead_path(lead)
        end
        m.failure do |failure|
          lead = Lead.new(lead_params)
          render :new
        end
      end
    end

    private

    def lead_params
      params.require(:lead).permit(
        :first_name, :last_name, :email
      )
    end
  end
```

Now, my controller only handles calls to a grouped set of business operations. No more database operations mingling with sending out emails or redirection rules. There is some cohesiveness in the abstraction.

This is it!

Y'all go and checkout [dry-transaction's documentation](https://dry-rb.org/gems/dry-transaction/) and do not hesitate [to read the source code](https://github.com/dry-rb/dry-transaction/) for more magic!

If you have any questions or if something is not clear enough, [ping me on Twitter](https://twitter.com/mercier_remi) or [create an issue on GitHub](https://github.com/merciremi/remicodes/issues/new) so we can make this tutorial better.

Next time, I'll show you how to test your transactions.

Cheers,

Rémi

---

[How to use transactions to unclutter your Rails controllers](https://remimercier.com/transactions-in-rails/) was originally published on [my blog](https://remimercier.com).