DEV Community

Cover image for How to avoid N + 1 and keep your Ruby on Rails controller clean
JetThoughts Dev for JetThoughts

Posted on • Edited on • Originally published at jetthoughts.com

How to avoid N + 1 and keep your Ruby on Rails controller clean

Where to put the logic related to database queries optimization in a Ruby on Rails application. We want to answer this question by showing you the ‘Facade’ approach.

Image description

Outline the problem

There is an application that stores a database of films and reviews. Every review has its own rate value.

class Film < ApplicationRecord
  has_many :reviews
end
Enter fullscreen mode Exit fullscreen mode
class Review < ApplicationRecord
  belongs_to :film
end
Enter fullscreen mode Exit fullscreen mode

We can calculate the average rate of each film,

class Film < ApplicationRecord
  has_many :reviews

  def avg_rating
    reviews.average(:rate)
  end
end
Enter fullscreen mode Exit fullscreen mode

and use this method in controller:

class FilmsController < ApplicationController
  def index
    @films = serialized_films
    render json: @films
  end

  def serialized_films
    Film.all.map do |film|
      {
        title: film.title,
        genre: film.genre,
        rate: film.avg_rating
      }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

If we need to show some films with their average ratings, this avg_rating method will issue an avg query every time it’s called, which will lead to a lot of individual queries.

Image description

How to reduce the query to two

Let’s update the controller to get all the data in just two queries. The first request receives data for all films. The second query uses an aggregation function for receiving an average rating for each movie and stores it in a cached variable.


class FilmsController < ApplicationController
  def index
    @films = serialized_films
    render json: @films
  end

  private

  def films_scope
    Film.all
  end

  def serialized_films
    films_scope.map do |film|
      { title: film.title, genre: film.genre, rate: films_rates[film.id] }
    end
  end

  def films_rates
    @films_rates ||= films_scope.includes(:reviews).group(:film_id).average(:rate)
  end
end
Enter fullscreen mode Exit fullscreen mode

And now we receive data without N+1 problem:

Image description

Where to store code for receiving data

But should we have this code in the controller? Let’s try to use the Facade pattern, and move the code to a separate FilmCarrier class.

class FilmCarrier
  delegate_missing_to :@film

  def initialize(film, cached_stats: nil)
    @film = film
    @cached_ratings_stats = cached_stats || calculate_stats(film)
  end

  def avg_rating
    @cached_ratings_stats[:avg_ratings][@film.id]
  end

  def self.wrap(film_scope)
    cache = { avg_ratings: calculate_totals(film_scope) }

    film_scope.map { |film| FilmCarrier.new(film, cached_stats: cache) }
  end

  def self.calculate_totals(films)
    films.includes(:reviews).group(:film_id).average(:rate)
  end

  def calculate_stats(film)
    { avg_ratings: { film.id => film.avg_rating } }
  end
end
Enter fullscreen mode Exit fullscreen mode

The class method wrap *is an implementation of the Facade pattern. It returns an array of the *FilmCarrier *instances for each film from the scope with the instance method *avg_rating.

And now we have clean controller:


class FilmsController < ApplicationController
  def index
    @films = serialized_films
    render json: @films
  end

  private

  def films_scope
    Film.all
  end

  def serialized_films
    FilmCarrier.wrap(films_scope).map do |film|
      { title: film.title, genre: film.genre, rate: film.avg_rating }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The pros of this way

  • Ability to combine data from the different tables

  • Group the related logic

  • The code is easier to test

Paul Keen is an Open Source Contributor and a Chief Technology Officer at JetThoughts. Follow him on LinkedIn or GitHub.

If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories.

Top comments (0)