Finder Objects
In Ruby applications it is considered good practice to encapsulate your ActiveRecord querying logic. To achieve this, it’s natural to use ActiveRecord scopes.
So, instead of this:
# app/models/quiz.rb
class Quiz < ActiveRecord::Base
end
# app/controllers/quizzes_controller.rb
class QuizzesController < ApplicationController
# BAD
def index
quizzes = Quiz.all
quizzes = quizzes.where(name: params[:q]) if params[:q]
quizzes = quizzes.where(category: params[:category]) if params[:category]
quizzes = quizzes.paginate(page: params[:page]) if params[:page]
render json: quizzes
end
end
It’s better to do this:
# app/models/quiz.rb
class Quiz < ActiveRecord::Base
scope :search, -> (params) do
quizzes = all
quizzes = quizzes.where(name: params[:q]) if params[:q]
quizzes = quizzes.where(category: params[:category]) if params[:category]
quizzes = quizzes.paginate(page: params[:page]) if params[:page]
quizzes
end
end
# app/controllers/quizzes_controller.rb
class QuizzesController < ApplicationController
# Good
def index
render json: Quiz.search(params)
end
end
This makes your controllers oblivious to the querying logic, as they should be. ActiveRecord scopes have always been convenient for encapsulating your query logic; they’re easily accessible, reusable anywhere in the scope chain, and are safe kept inside a class which has the appropriate “database” responsibility.
However, there are downsides to putting everything in ActiveRecord scopes:
- They don’t bring new behaviour to your models, they are essentially just macros for querying.
- As your application grows, the scopes just keep piling up in your ActiveRecord models. And it doesn’t have to necessarily mean that your models are getting more complex, it could just mean that you’re presenting your data in various ways, and that you need a lot of scopes.
- You’re limited to scope names that are different than existing ActiveRecord’s query methods. It may not sound like a big deal, but as your application grows you may start minding this lack of freedom.
- You cannot override existing query methods (e.g.
#find
) to do some custom logic. By “cannot” I mean that you shouldn’t, because you could break existing code which is relying on original ActiveRecord functionality. You can extend a query method with additional funcionality, but it may turn out to be more work than you hoped.
There is a better way.
Finder objects
Finder objects are classes which encapsulate querying the database. The idea is that your controllers always query your records through finder objects, never through models directly.
You may be more familiar with the term “Query objects”. These are similar to finder objects, but the idea seems to be to create one class for each query. I don’t like that, because then you end up with a lot of unmeaningful classes, it’s better to have multiple methods per class.
A generalization of finder objects are “Repositories”, which encapsulate all interaction with the database, not just querying. However, I really like the ActiveRecord pattern, so I don’t need repositories, but I would like to extract the querying part.
Implementation
Suprisingly, I didn’t find any examples on the implementation of finder objects. I first found out about them in this presentation held by Simone Carletti from DNSimple. The presentation doesn’t contain the actual implemenation, but it contains enough examples of usage to understand the main features finder objects should have.
Let’s write the simplest finder object:
# app/finders/quiz_finder.rb
class QuizFinder
def self.search(q: nil, category: nil, page: nil)
quizzes = Quiz.all
quizzes = quizzes.where(name: q) if q
quizzes = quizzes.where(category: category) if category
quizzes = quizzes.paginate(page: page) if page
quizzes
end
def self.published
Quiz.where(published: true)
end
end
QuizFinder.search(q: "game of thrones", category: "movies")
This is of course very raw, but it’s a good start. We quickly realize we don’t
want to repeat the Quiz
constant for each query method, so we DRY it up:
# app/finders/quiz_finder.rb
class QuizFinder
def self.search(q: nil, category: nil, page: nil)
quizzes = scope
quizzes = quizzes.where(name: q) if q
quizzes = quizzes.where(category: category) if category
quizzes = quizzes.paginate(page: page) if page
quizzes
end
def self.published
scope.where(published: true)
end
def self.scope
Quiz.all
end
end
Better. This will be useful later. Ok, now we notice that we cannot reuse query
methods (we cannot use .published
inside of .search
), because they both have to
be called on QuizFinder
which is currently stateless.
Quickly we come to the idea to have QuizFinder
be instantiated with a scope,
and turn our query methods into instance methods, so we change our
implementation:
# app/finders/quiz_finder.rb
class QuizFinder
def self.method_missing(name, *args, &block)
new(Quiz.all).send(name, *args, &block)
end
def initialize(scope)
@scope = scope
end
def search(q: nil, category: nil, page: nil)
quizzes = published # we can now reuse this query method
quizzes = quizzes.where(name: q) if q
quizzes = quizzes.where(category: category) if category
quizzes = quizzes.paginate(page: page) if page
quizzes
end
def published
scope.where(published: true)
end
private
attr_reader :scope
end
Notice that we could now reuse #published
inside of #search
. We added the
class-level .method_missing
so that we can still call methods on the
class-level (I find it prettier).
Let’s now refactor #search
to prove that our finder object implementation
works when we increase complexity (we also add #new
to make the code
more concise).
# app/finders/quiz_finder.rb
class QuizFinder
def self.method_missing(name, *args, &block)
new(Quiz.all).send(name, *args, &block)
end
def initialize(scope)
@scope = scope
end
def search(q: nil, category: nil, page: nil)
quizzes = published
quizzes = new(quizzes).from_query(q) if q
quizzes = new(quizzes).with_category(category) if category
quizzes = new(quizzes).paginate(page) if page
quizzes
end
def published
scope.where(published: true)
end
def from_query(q)
scope.where(name: q)
end
def with_category(category)
scope.where(category: category)
end
def paginate(page)
scope.paginate(page: page)
end
private
attr_reader :scope
def new(*args)
self.class.new(*args)
end
end
It looks like our implementation scales. The final step is to extract this functionality out so that we can add other finder objects:
# app/finders/base_finder.rb
class BaseFinder
def self.method_missing(name, *args, &block)
new(model.all).send(name, *args, &block)
end
def self.model(klass = nil)
if klass
@model = klass
else
@model
end
end
def initialize(scope)
@scope = scope
end
def paginate(page)
scope.paginate(page: page)
end
private
attr_reader :scope
def new(*args)
self.class.new(*args)
end
end
# app/finders/quiz_finder.rb
class QuizFinder < BaseFinder
model Quiz
def search(q: nil, category: nil, page: nil)
quizzes = published
quizzes = new(quizzes).from_query(q) if q
quizzes = new(quizzes).with_category(category) if category
quizzes = new(quizzes).paginate(page) if page
quizzes
end
def published
scope.where(published: true)
end
def from_query(q)
scope.where(name: q)
end
def with_category(category)
scope.where(category: category)
end
end
Advantages
- Your queries are now completely isolated in their own classes, and aren’t cluttering your models anymore.
- You now have the complete freedom over the query interface. So you can now
(re)implement
.find
with confidence that you won’t break anything. You can also raise “not found” errors whenever you want, and you can choose instead ofActiveRecord::RecordNotFound
to use a custom application-specific error (useful when building APIs). - You can now group your query methods however you want, if your
QuizFinder
increases in complexity, you can split it up to multiple finder objects (which is much better than splitting ActiveRecord scopes into multiple concerns). - You can now easily impose a rule that controllers always have to query the models through finder objects, ensuring encapsulation (when using ActiveRecord scopes, it can always happen that controller accidentally calls one of ActiveRecord’s finder methods).
Conclusion
When your application’s complexity increases, your models are usually the ones who suffer the most from it. Therefore, it is important to figure out which things don’t have to be in the model, but still try to find a way to keep the inteface convenient. Finder objects are a great way of reducing your models’ complexity.