Rails: Facade Design Pattern for Index
K Putra
Posted on December 26, 2019
If you continue to read this article, I assume that you know Ruby, OOP in Ruby, RoR, Active Record, and a little bit metaprogramming.
What is Facade Design Pattern?
Facade Design Patter provide a unified interface to a set of interfaces in a subsystem. Facade defines a higher-level interface that makes the subsystem easier to use.
source: GoF in Design Patterns - Elements of Reusable Object-Oriented Software
Perhaps you should read these articles before we start:
Rails: Query Object Pattern Implementation
Rails: skinny controller, skinny model
Let's start our journey! (I use Rails API-only as example, but this article can be implemented in normal Rails as well)
Table of Contents:
1. Problems
2. First Solution (bad)
3. Second Solution (a bit better)
4. Third Solution (much better)
5. The Cost
6. Final Word
1. Problems
In Fintax, the FE team asked me (the only one BE developer) to give this kind of response for index api:
{
"object": [
{
"column": "object1"
},
{
"column": "object2"
}
],
"page": {
"total_record": 20,
"per_page": 2
}
}
Okay, it's not hard. Let's move to first solution.
2. First Solution (bad)
Let assume we use will_paginate gems (I actually use that). For beginner, these will be their controllers:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
users = User.where(nil)
users = users.where(role: params[:role]) if params[:role]
users = users.where(status: params[:status]) if params[:status]
users = users.where(public_id: params[:public]) if params[:public]
users = users.paginate(page: params[:page], per_page: 2)
users_count = users.count
render json: { object: users, page: { total_record: users_count, per_page: 2 }}, status: 200
end
end
# app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
def index
companies = Company.where(nil)
companies = companies.where("name like ?", "#{params[:name]}%") if params[:name]
companies = companies.where(tax_id: params[:tax]) if params[:tax]
companies = companies.paginate(page: params[:page], per_page: 2)
companies_count = companies.count
render json: { object: companies, page: { total_record: companies_count, per_page: 2 }}, status: 200
end
end
This kind of controllers is very bad, because of various reasons:
- I have a dozen of models that need to be indexed (and this number will grow in the future), so this kind of code not only very repetitive, but also very hard to maintained;
- The total objects per page are not dynamic, so if the FE team asked to change it, we have to change all controllers;
- We send every columns exist in the model to FE;
- etc.
Let's move to our next solution.
3. Second Solution (a bit better)
We will use filterable module which I explain in my previous article.
We will use pluck. To be precise, I use pluck_all gems. Why we use pluck? So we don't send every columns to FE. We just give them what they need. Why we don't use as_json
or map
? Because pluck is better in performance.
We will make our pagination to become dynamic. Tell FE team that they can throw per_page
parameter if someday they want to change total objects per page.
So, our controllers and models will be like this:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
users = User.call_index(params)
users_count = users.count
users = users.show_index
render json: { object: users, page: { total_record: users_count, per_page: (params[:per_page] || 2) }}, status: 200
end
end
# app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
def index
companies = Company.call_index(params)
companies_count = companies.count
companies = companies.show_index
render json: { object: companies, page: { total_record: companies_count, per_page: (params[:per_page] || 2) }}, status: 200
end
end
# app/models/user.rb
class User < ApplicationRecord
include Filterable
scope :role, -> (role) { where(role: role) }
scope :status, -> (status) { where(status: status) }
scope :public, -> (public_id) { where(public_id: public_id) }
def self.call_index(params)
filter(params.slice(:role, :status, :public))
.paginate(page: params[:page], per_page: (params[:per_page] || 2)
end
def self.show_index
pluck_all(:id, :role, :status, :public)
end
end
# app/models/company.rb
class Company < ApplicationRecord
include Filterable
scope :name, -> (name) { where("name like ?", "#{name}%") }
scope :tax, -> (tax_id) { where(tax_id: tax_id) }
def self.call_index(params)
filter(params.slice(:name, :tax))
.paginate(page: params[:page], per_page: (params[:per_page] || 2)
end
def self.show_index
pluck_all(:id, :name, :tax)
end
end
I hope you realize 2 things:
First, I use pluck
after counting total object.
If I use count
after pluck
, I call method count
from Array class, not count
from Active Record. So, I'll get length of array instead of total object from active record.
Second, if you have already read Rails: Refactor Your Where Method, notice that I delete filtering_(params)
method from controllers, and filter the params in models.
If I filter the params in controller as before and add page
and per_page
parameters, then the filter
method will search scope named page
and per_page
. Because these scopes do not exist, then rails will throw error.
Tips: There are many ways to move params.slice()
to another method. Why we need to move it? Imagine you have 10 keys in the params hash! One of the ways are: we create class inside model, like the code below. That way, the code is more cleaner (method should do one thing), and params.slice()
is reusable inside the model.
# app/models/user.rb
class User < ApplicationRecord
include Filterable
scope :role, -> (role) { where(role: role) }
scope :status, -> (status) { where(status: status) }
scope :public, -> (public_id) { where(public_id: public_id) }
def self.call_index(params)
filter(Whitelisting.call(params))
.paginate(page: params[:page], per_page: (params[:per_page] || 2)
end
def self.show_index
pluck_all(:id, :role, :status, :public)
end
class Whitelisting
def self.call(params)
params.slice(:role, :status, :public)
end
end
end
Okay, this is better than initial solution. Now, let's implement Facade Pattern !
4. Third Solution (much better)
Let's implement Facade Pattern. We'll make app/lib
directory, and create index_facade.rb
.
In short, index_facade.rb
is the unified interface to a set of interfaces in a subsystem.
# app/lib/index_facade.rb
class IndexFacade
def initialize(model, params)
@model = model.constantize
@params = params
@objects = objects
end
def call
{
object: @objects.show_index
page: {
total_record: @objects.counts
per_page: per_page
}
}
end
private
def objects
@model.call_index(@params)
end
def per_page
@params[:per_page] || 2
end
end
Now, we'll update our controllers:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
users = IndexFacade.new('User', params).call
render json: users, status: 200
end
end
# app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
def index
companies = IndexFacade.new('Company', params).call
render json: companies, status: 200
end
end
Voila! Look at our new controllers!
5. The Cost
The only costs are:
In every model, we have to include Filterable
.
In every model, we have to add call_index
and show_index
method, as in User
and Company
.
What if I want index of Article
is giving every column and have no filter?
# app/models/article.rb
class Article < ApplicationController
include Filterable
def self.call_index(params)
filter(params.slice())
.paginate(page: params[:page], per_page: (params[:per_page] || 2)
end
def self.show_index
pluck_all
end
end
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
articles = IndexFacade.new('Article', params).call
render json: articles, status: 200
end
end
6. Final Word
If you have opinion or better implementation, or may be I was wrong about Facade Pattern, let discuss in comment.
source: myself
Posted on December 26, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.