DEV Community

Evil Martians

Carve your controllers like Papa Carlo

palkan_tula profile image Vladimir Dementyev Originally published at Updated on ・4 min read

We, Rails developers, tend to keep our controllers skinny (and models fat–oh, wait, it's not true anymore; now we have fat services 😉).

We add different layers of abstractions: interactors, policies, query objects, form objects, you name it.

And we still have to write something like this when dealing with query params-based filters:

class EventsController < ApplicationController
  def index
    events = Event.all.
      page(params[:page] || 1).

    events = events.where(
      type: params[:type_filter]
    ) if params[:type_filter].in?(%w[published draft])

    events = events.future if params[:time_filter] == "future"
    # NOTE: `searched` is a scope or class method defined on the Event model
    events = events.searched(params[:q]) if params[:q].present?

    render json: events

  def sort_params
    sort_by = params[:sort_by].in?(%w[id name started_at]) ?
              params[:sort_by] :
    sort_order = params[:sort].in?(%w[asc desc]) ? params[:sort] : :desc
    { sort_by => sort_order }

Despite having a non-skinny controller, we have the code which is hard to read, test and maintain.

I want to show how we can carve this controller (just like Papa Carlo carved BuratinoRussian Pinocchio–from a log) using a new gem–Rubanok (which means "hand plane" in Russian).

Rubanok is a general-purpose tool for data transformation driven by Hash-based params.

Ok, that sounds weird 😕

Let just look at our example above: we take our data (Active Record relation, Event.all) and transform it according to the user's input (params object).

What if we could extract this transformation somewhere out of the controller?

You may ask: "What's the point of this abstraction"?

There are several reasons:

  • Make our code more readable (less logic branching)
  • Make our code easier to test (and make tests faster)
  • Make our code reusable (e.g., sorting and pagination logic is likely used in other controllers, too).

Let me first show you how the above controller looks when we add Rubanok:

class EventsController < ApplicationController
  def index
    events = planish Event.all
    render json: events

That's it. It couldn't be slimmer (ok, we can make render json: planish(Event.all)).

What's hidden under the planish method?

It's a Rails-specific method (btw, Rubanok itself is Rails-free) that utilizes convention over configuration principle and could be unfolded into the following:

def index
  events =, params.to_unsafe_h)
  render json: events

And the EventsPlane class is where all the magic transformation happens:

class EventsPlane < Rubanok::Plane
  TYPES = %w[draft published].freeze
  SORT_FIELDS = %w[id name started_at].freeze
  SORT_ORDERS = %w[asc desc].freeze

  map :page, activate_always: true do |page: 1|

  map :type_filter do |type_filter:|
    next raw.none unless TYPES.include?(type_filter)

    raw.where(type: type_filter)    

  match :time_filter do
    having "future" do

    default { |_time_filter| raw.none }

  map :sort_by, :sort do |sort_by: "started_at", sort: "desc"|
    next raw unless SORT_FIELDS.include?(sort_by) &&
    raw.order(sort_by => sort)

  map :q do |q:|

The plane class describes how to transform data (accessible via raw method) according to the passed params:

  • Use map to extract key(-s) and apply a transformation if the corresponding values are not empty (i.e., empty strings are ignored); and you can rely on Ruby keyword arguments defaults here–cool, right?
  • Use match take values into account as well when choosing a transformer.

Now we can write tests for our plane in isolation:

describe EventsPlane do
  let(:input) { Event.all }
  # add default transformations
  let(:output) { :desc) }
  let(:params) { {} }

  # we match the resulting SQL query and do not make real queries
  # at all–our tests are fast!
  subject {, params).to_sql }

  specify "q=?" do
    params[:q] = "wood"

    expect(subject).to eq(output.searched("wood").to_sql)

  specify "type_filter=<valid>" do
    params[:type_filter] = "draft"

    expect(subject).to eq(output.where(type: "draft").to_sql)

  specify "type_filter=<invalid>" do
    params[:type_filter] = "unpublished"

    expect(subject).to eq(output.none.to_sql)

  # ...

In your controller/request test all you need is to check that a specific plane has been used:

describe EventsController do
  subject { get :index }

  specify do
    expect { subject }.to have_planished(Event.all).

So, Rubanok is good for carving controllers, but we said that it's general-purpose–let's prove it with GraphQL example!

module GraphAPI
  module Types
    class Query < GraphQL::Schema::Object
      field :profiles, Types::Profile.connection_type, null: false do
        argument :city, Int, required: false
        argument :home, Int, required: false
        argument :tags, [ID], required: false
        argument :q, String, required: false

      def profiles(**params), params)

It looks like we've just invented skinny types 🙂

Check out Rubanok repo for more information and feel free to propose your ideas!

P.S. There is an older gem filterer which implements a similar idea (though in PORO way), but focuses on ActiveRecord and lacks testing support.

P.P.S. Wondering what other abstractions we use to organize code in large applications? Check out my other posts, such as "Crafting user notifications in Rails with Active Delivery" or "Clowne: Clone Ruby models with a smile", and projects, such as Action Policy and Anyway Config.

Read more dev articles on!

Discussion (4)

Editor guide
maestromac profile image
Mac Siri

Oh I like it. I'm going to give it a try. Would you put Plane classes in it's own folder under app?

palkan_tula profile image
Vladimir Dementyev Author

Yeah, I put them under app/planes.

Thanks for the question. Added this to the Readme

adis_io profile image
Adis Osmonov

It'd be great to hear why is your solution is better than others, for example: dry-schema

palkan_tula profile image
Vladimir Dementyev Author

dry-schema is a totally different tool

dry-schema is a data coercion and validation library

Rubanok doesn't do neither validation nor coercion, it takes input data and pass it through the transformers according to the params