#rails
Posted March 18, 2021 ‐  4 min read

Rails controller : The Rails Way vs functional approach.

The use case

Let's use a very simple case. A controller must render a "page". If the slug (a textual ID in the URL) doesn't exist, then render a classic 404. If it exists, but not yet published, render a 422 error. Else, simply render the page.

The Rails Way™

From what I have been taught (and from the docs), here how I would solve the problem :

class PageController < ApplicationController

  # GET /:page_slug(.json)
  def show

    page = Page.find_by(slug: params[:page_slug])

    if page.nil?
      render template: "errors/not_found", status: 404
    elsif !page.published?
      render template: "errors/unacceptable", status: 422
    else
      reply_h = {
        response: {      
          data: {
            content_value: page.content.value,
            updated_at: page.updated_at
          }
        }      
      }

      respond_to do |format|
        format.html { render locals: reply_h;  }
        format.json { render json: reply_h; }
      end
    end
  end
end

Interesting parts (or more precisely, opinionated, and opened-to-criticism parts) :

So what's wrong with "The Rails Way" ? Controllers are not easy to test. How to test them is still a debate today within the community. Officially, you can test them by sending HTTP requests, and check the content of the response. See this part of the docs. Which is actually slow, integration testing. Having a controller that is not completely isolated and independently testable is still good : you have less files, less code, more coding speed.

So now, what if I want a completely isolated and testable controller ?

Functional programming approach

I try here not to overthink the "functional programming" notion. Thank you Matthieu Cneude for the insight :

To me, functional programming is more a set of guidelines: use immutability as much as you can, try to avoid side effects, and make your functions predictable (by always returning the same output with a given input).
Matthieu Cneude (@Cneude_Matthieu) - Twitter, March 12, 2021

So if I want a completely isolated behaviour for my "show page" function, I have to delegate immediately the call inside the controller, like this :

class PageController < ApplicationController

  # GET /:page_slug(.json)
  def show
    # delegate and call the service object
    ShowPage.new.call(slug: params[:page_slug], ctrl: self)
  end

end

And now we have a completely isolated, predictable, testable service :

# inside services/show_page.rb
# Plain Old Ruby Object with only "call" method
class ShowPage

  def call(slug: '', ctrl: PageController)
    page = Page.find_by(slug: slug)
    if page.nil?
      ctrl.render(template: "errors/not_found", status: 404)
    elsif !page.published?
      ctrl.render(template: "errors/not_found", status: 422)
    else
      reply_h = {
        response: {      
          data: {
            content_value: page.content.value,
            updated_at: page.updated_at
          }
        }      
      }
      ctrl.respond_to do |format|
        format.html { ctrl.render locals: reply_h;  }
        format.json { ctrl.render json: reply_h; }
      end
    end
  end
end

Thus, a unit test will look like this :

# inside test/services/show_page_test.rb

require 'test_helper'

class ShowPageTest < ActiveSupport::TestCase

  test 'If page not found, returns 404' do

    create_unpublished_page
    mocked_controller = OpenStruct.new

    mocked_controller.expects(:render).with(template: "errors/not_found", status: 404)

    ShowPage.new.call(slug: nil, ctrl: mocked_controller)
  end

  test 'If page not published, returns 422' do

    create_unpublished_page
    mocked_controller = OpenStruct.new
    mocked_controller.expects(:render).with(template: "errors/unacceptable", status: 422)

    ShowPage.new.call(slug: 'about', ctrl: mocked_controller)
  end

  test 'If page published, the controller is able to reply HTML' do
    page = create_unpublished_page
    page.publication_status = 'published'
    page.save!

    mocked_html = OpenStruct.new
    mocked_format = OpenStruct.new
    mocked_controller = OpenStruct.new

    mocked_controller.expects(:respond_to).yields(mocked_format)
    mocked_format.expects(:html).yields
    mocked_controller.expects(:render).with(has_key(:locals))

    ShowPage.new.call(slug: 'about', ctrl: mocked_controller)
  end

end

I have mixed feelings about this.

Conclusion

I'm staying away from the functional approach, by now. I have to admit that Rails architecture has already been thought, defined, battle-tested and discussed by many means, and going against this could hurt, sooner or later.

For me, it has one consequence : high-level testing should be first-class citizen when using Rails IMHO.

Help needed 😊

If you enjoyed the article, you can :

  • Share the article on Twitter , or LinkedIn , or Reddit , it will stimulate the writing effort, thanks !
  • Subscribe to the newsletter, you'll be warned each time a new Rails tutorial is released : we start from the rails new command to fully understand a new concept.
  • Subscribe to the Bootrails Beta, it's a tool to launch new Rails apps.

Thanks to all,

David