Business logic in Rails with operators
ruby, rails, business logic, poro
19 Oct 2021 ・ Featured at Ruby Weekly #575
Having a thousand lines long controllers and/or models is not the right way to have sustainable applications or developers’ sanity. Let’s look at this straightforward solution for business logic in the Rails app.
Why?
Why should you not have such long controllers/models (or even views)? There are a lot of reasons. From worse sustainability, readability, to worse testability. But mainly, they all affect the developer’s happiness.
I can gladly recommend Sustainable Web Development with Ruby on Rails from David Bryant Copeland where he did a great job explaining it all.
What did I want from the solution?
I can’t say this was not influenced by other solutions. For example, I used Trailblazer before. But none of what I read about or used was the one I would like.
When I read a solution from Josef Strzibny, I realized that I should write down this easy approach to get some feedback.
Here is what I wanted to achieve:
- nothing complex
- naming easy as possible
- simple file structure
- OOP and its benefits (even for results)
- easy testability
- in general - as few later decisions as possible
The solution
I will demonstrate the solution on a simple Invoice
model with a corresponding InvoicesController
.
Naming and structure
The first thing is the naming and the corresponding file structure. I chose the Operator
suffix. In our case, it will be InvoiceOperator
inside the app/operators
folder.
The suffix makes everything easier - the developer will always know what to use for any model, it is just a simple <ModelName>Operator
.
Naming is hard, especially for non-native speakers. If you find a better name, let me know!
So, we have the class name, but what about its methods? It will be, mainly but not only, used in controllers. As Rails controllers are already breaking the single-responsibility principle, I will not hesitate to continue with that to have things easier.
To make it even easier, let’s use the classic RESTful names for methods. For the create
action in the controller, it will look like this:
# app/operators/invoice_operator.rb
class InvoiceOperator
def create(params:)
# ...
end
end
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
def create
result = InvoiceOperator.new.create(params: invoice_params)
# ...
end
end
So, every model will have its operator and in every operator, we will know what methods should be used in each action. Everything is easily predictable in most cases.
Except… the new
action in a controller. Having InvoiceOperator.new.new
does not look cool to me. Luckily for most cases, we don’t need it and we can use the simple Invoice.new
.
If we will need to apply complex logic (and thus use the operator), we can use a prepare
method instead of the new
. It is not perfect to the previous statement, but the naming makes sense to me.
Result object
Using the result object is a common strategy. The base concept is the same for every operator, so we won’t repeat it in every operator. Let’s create a BaseOperator
class.
This will also help us not to think about the name of the method with our object (in our case the invoice). It will always be the result.record
and not eg. result.invoice
.
# app/operators/base_operator.rb
class BaseOperator
def initialize(record: nil)
@record = record || new_record
end
private
def new_record
raise NotImplementedError
end
class Result
attr_reader :record, :meta
def initialize(state:, record: nil, **meta)
@state = state
@record = record
@meta = meta
end
def success?
!!@state
end
end
end
And use it for our InvoiceOperator
:
# app/operators/invoice_operator.rb
class InvoiceOperator < BaseOperator
def update(params:)
@record.assign_attributes(params)
# do things before updating the invoice eg:
# update/create related records (like InvoiceItems)
# a few more examples:
calculate_total
calculate_vat
state = @record.save
# do your other business eg.:
# send emails,
# call external services and so on
Result.new(state: state, record: @record)
end
def create(params:)
@record.assign_attributes(params)
# do things before creating the invoice eg:
# create related records (like InvoiceItems)
# a few more examples:
calculate_total
calculate_vat
state = @record.save
# do your other business eg.:
# send emails,
# call external services and so on
Result.new(state: state, record: @record)
end
private
def new_record
Invoice.new
end
def calculate_total
# you can write the logic here,
# or call a class that handles the calculation
end
def calculate_vat
# you can write the logic here,
# or call a class that handles the calculation
end
end
The BaseOperator
also introduced the initialize
method. That will help us to use the operator in two ways:
- with a new record: eg.
InvoiceOperator.new.create(params: invoice_params)
where it will useInvoice.new
- with the existing record: eg.
InvoiceOperator.new(record: Invoice.find(params[:id])).update(params: invoice_params)
The Result
object uses a state
variable. I like this way more than using two objects (one for success and one for failure). It is also much simpler for testing.
The private method new_record
can be also used for setting the right “blank” object (eg. with some defaults).
And now, the example usage in the controller:
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
def create
result = InvoiceOperator.new.create(params: invoice_params)
if result.success?
redirect_to result.record, notice: "Created!"
else
render :new, locals: {
invoice: result.record
}, status: :unprocessable_entity
end
end
def update
result = InvoiceOperator.new(record: Invoice.find(params[:id]))
.update(params: invoice_params)
if result.success?
redirect_to result.record, notice: "Updated!"
else
render :edit, locals: {
invoice: result.record
}, status: :unprocessable_entity
end
end
end
Custom actions in controllers
If you are using custom actions in controllers, you can continue to have the same method name in the operator.
If you don’t and you are using only RESTful actions, you can end up with this:
module Invoices
class DuplicatesController < ApplicationController
def create
original_invoice = Invoice.find(params[:id])
result = InvoiceOperator.new(record: original_invoice).duplicate
if result.success?
redirect_to result.record, notice: "Duplicated!"
else
redirect_back fallback_location: original_invoice, allow_other_host: false
end
end
end
end
In this case, the action create
does not correspond with the operator’s duplicate
method, but at least, the controller name is related to it. That should help with a decision on what name should be used.
Other possible solution could be to use a new operator (eg. InvoiceDuplicateOperator
) that would inherit from InvoiceOperator
and has the right create
action.
Testing
I mentioned testing several times. Here is a simplified example for testing the operator.
# spec/operators/invoice_operator_spec.rb
RSpec.describe InvoiceOperator, type: :operator do
let(:invoice) {}
let(:company) { create(:company) }
let(:operator) { described_class.new(record: invoice) }
describe "create" do
let(:params) do
ActionController::Parameters.new({
"company_id" => company.id,
"date_from" => "2021-01-01",
"date_to" => "2021-01-31",
"due_at" => "2021-01-16"
})
end
it "creates a record" do
result = operator.create(params: params)
expect(result).to be_success
expect(result.record.persisted?).to be_truthy
end
end
describe "update" do
let(:invoice) { create(:invoice, paid_at: nil) }
let(:params) do
ActionController::Parameters.new({
"paid_at" => "2021-01-18"
})
end
it "updates a record" do
result = operator.update(params: params)
expect(result).to be_success
expect(result.record.paid_at).not_to be_nil
end
end
end
And here is a simplified spec for the create
action:
# spec/requests/invoices_spec.rb
RSpec.describe "Invoices", type: :request, signed_in: true do
let(:current_user) { create(:user) }
let(:invoice) { create(:invoice, date_from: "2021-01-01", date_to: "2021-01-31") }
describe "create" do
before do
allow(InvoiceOperator).to receive_message_chain(:new, :create).and_return(
instance_double("BaseOperator::Result", success?: success, record: invoice)
)
post invoices_path, params: {invoice: {title: "Just Invoice"}}
end
context "with successful result" do
let(:success) { true }
it { expect(response).to have_http_status(:found) }
end
context "without successful result" do
let(:success) { false }
it { expect(response).to have_http_status(:unprocessable_entity) }
end
end
end
Summary
This solution was not battle-tested in a large Rails application for a long period of time. But I think it is a simple, readable, predictable and extendable solution.
It solved a lot of what I wanted from it. I am already using it in one application and I, obviously, like it.
I would really welcome any feedback and I hope we can together find an even better solution.
Do you like it? You can subscribe to RSS (you know how), or follow me on Mastodon.