Business logic in Rails with operators

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 my solution for business logic in the Rails app.

Spoiler alert: yes, I will use PORO… again.


This content originally appeared on DEV Community and was authored by Petr Hlavicka

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 my solution for business logic in the Rails app.

Spoiler alert: yes, I will use PORO... again.

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 I 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 my approach to get some feedback.

Here is what I wanted to achieve:

  1. nothing complex
  2. naming easy as possible
  3. simple file structure
  4. OOP and its benefits (even for results)
  5. easy testability
  6. 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 File 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 your business

    Result.new(state: @record.save, record: @record)
  end

  def create(params:)
    @record.assign_attributes(params)
    # do your business

    Result.new(state: @record.save, record: @record)
  end

  private

  def new_record
    Invoice.new
  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 use Invoice.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.


This content originally appeared on DEV Community and was authored by Petr Hlavicka


Print Share Comment Cite Upload Translate Updates
APA

Petr Hlavicka | Sciencx (2021-10-19T11:43:17+00:00) Business logic in Rails with operators. Retrieved from https://www.scien.cx/2021/10/19/business-logic-in-rails-with-operators/

MLA
" » Business logic in Rails with operators." Petr Hlavicka | Sciencx - Tuesday October 19, 2021, https://www.scien.cx/2021/10/19/business-logic-in-rails-with-operators/
HARVARD
Petr Hlavicka | Sciencx Tuesday October 19, 2021 » Business logic in Rails with operators., viewed ,<https://www.scien.cx/2021/10/19/business-logic-in-rails-with-operators/>
VANCOUVER
Petr Hlavicka | Sciencx - » Business logic in Rails with operators. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2021/10/19/business-logic-in-rails-with-operators/
CHICAGO
" » Business logic in Rails with operators." Petr Hlavicka | Sciencx - Accessed . https://www.scien.cx/2021/10/19/business-logic-in-rails-with-operators/
IEEE
" » Business logic in Rails with operators." Petr Hlavicka | Sciencx [Online]. Available: https://www.scien.cx/2021/10/19/business-logic-in-rails-with-operators/. [Accessed: ]
rf:citation
» Business logic in Rails with operators | Petr Hlavicka | Sciencx | https://www.scien.cx/2021/10/19/business-logic-in-rails-with-operators/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.