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 whose documentation is neat.
From clean to messy controllers in no time
Let's start with coding a basic controller.
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 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
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:
gem 'dry-transaction'
Then execute:
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.
A business transaction is a series of operations where any can fail and stop the processing.
Each step is processed one at a time and must return either a Success
or a Failure
object.
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 aSuccess
. This is fine here because I'm simply fetching an input. -
step
is the basic operation. It'll have to return either aSuccess
or aFailure
object. -
@params
will be available to all other steps or methods of my transaction whenlead
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:
- app
- controllers
- application_controller.rb
- leads_controller.rb
At the end of this tutorial, it'll be:
- app
- controllers
- application_controller.rb
- leads_controller.rb
- transactions
- base_transaction.rb
- leads
- create.rb
Let's build base_transaction.rb
first.
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.
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.
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?
Calling a transaction will run its operations in their specified order, with the output of each operation becoming the input for the next.
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!
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 and do not hesitate to read the source code for more magic!
If you have any questions or if something is not clear enough, ping me on Twitter or create an issue on GitHub 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 was originally published on my blog.
Top comments (8)
Cool article Rémi. I'm confused about why you would use
dry-transactions
when Rails already uses transactions in model callbacks.For instance, you could do
after_save :send_emails
, and then put all of your logic within a private method you would end up with a similar situation. All of the logic would be wrapped and protected in the protective transaction cover.I think the naming that the
dry-transactions
gem uses is definitely throwing people here (and I think missing the point Rémi is trying to make). This is less about wrapping code chunks in database transactions, and more about extracting business logic into processable steps to better align with how operators of the business perceive the flow of their clients (users).I've used a couple other gems (namely trailblazer, and waterfall) which provide an entire framework for taking this idea a step further. Having worked with Trailblazer the most recent, I can say that simply having a rigid structure for processing data through chainable events (as is done in this great article) was an incredible dev experience that clearly defined a picture of what was happening and why.
Rails controllers are great for gathering data and directing it to the right pipeline, but I can't say I've come across anything remarkable in Rails that helps establish these pipelines.
I wouldn't have been able to explain this better. Thanks @codenamev for this great explanation and the kind words!
Hey Kobe, thanks for your question. To be honest, I wouldn't know how to answer this one.
Could I add several
after_save
and handle the return of each action?I was going to post the same thing. This can be handled by rails without the need of an additional gem.
Maybe I am missing something.
I thought that
Lead.transaction
- to use your example - only allowed me to wrap database operations as a single unit. With thedry-transaction
it's more about any kind of operations. But maybe I am missing something.What if your sms service failed? You render 'new' action as if nothing has happened, meanwhile Lead is persisted in the database and in the remote system, right? Then user sends form once again, and you have one more Lead in the db and in remote system. Am I missing something?
In the transaction has it's written above, yes. This is what would happen.
I'd include a first step that checks if the new lead already exists in the DB and branch out if that's the case. This really depends on your business logic. :)